Compare commits
35 Commits
main
...
fix-remove
| Author | SHA1 | Date | |
|---|---|---|---|
| 659bae09ad | |||
| 01dab44695 | |||
| 65dce73681 | |||
| 45e62fb2ef | |||
| 0d38f81e2c | |||
| 3614271881 | |||
| 1fe587c187 | |||
| 06cb41621a | |||
| dcb9179133 | |||
| d8650a7822 | |||
| a8aac83f32 | |||
| 5d7dfa33e2 | |||
| cee64dd9b2 | |||
| e3623821e0 | |||
| 923df5fc4a | |||
| 847e2b6810 | |||
| fa2859dd52 | |||
| 41eca45e07 | |||
| cf5f775e0f | |||
| 5cafc8a80b | |||
| 66b0d8192f | |||
| 2fe4cd0d68 | |||
| dfdc5cc104 | |||
| 263bda78e5 | |||
| 77b9063254 | |||
| dfcd9a8da2 | |||
| 21da20d927 | |||
| 17f1bd678e | |||
| 31b904f6c5 | |||
| 91afbd24f8 | |||
| c617934a54 | |||
| f292192e34 | |||
| 34b4095a20 | |||
| e28671e90f | |||
| a77f62f949 |
20
.env.example
@ -17,6 +17,26 @@ GOOGLE_CLIENT_SECRET="your-google-client-secret"
|
|||||||
GITHUB_CLIENT_ID="your-github-client-id"
|
GITHUB_CLIENT_ID="your-github-client-id"
|
||||||
GITHUB_CLIENT_SECRET="your-github-client-secret"
|
GITHUB_CLIENT_SECRET="your-github-client-secret"
|
||||||
|
|
||||||
|
# Nextcloud Configuration
|
||||||
|
# Nextcloud instance base URL
|
||||||
|
NEXTCLOUD_BASE_URL="https://portal.united-tattoos.com"
|
||||||
|
|
||||||
|
# Nextcloud CalDAV Integration (Optional)
|
||||||
|
# Service account credentials for calendar sync
|
||||||
|
NEXTCLOUD_USERNAME="your-nextcloud-service-account"
|
||||||
|
NEXTCLOUD_PASSWORD="your-nextcloud-app-password"
|
||||||
|
NEXTCLOUD_CALENDAR_BASE_PATH="/remote.php/dav/calendars"
|
||||||
|
|
||||||
|
# Nextcloud OAuth Authentication
|
||||||
|
# OAuth app credentials for artist authentication
|
||||||
|
NEXTCLOUD_OAUTH_CLIENT_ID="your-nextcloud-oauth-client-id"
|
||||||
|
NEXTCLOUD_OAUTH_CLIENT_SECRET="your-nextcloud-oauth-client-secret"
|
||||||
|
|
||||||
|
# Nextcloud group name for auto-provisioning artists (default: "artists")
|
||||||
|
NEXTCLOUD_ARTISTS_GROUP="artists"
|
||||||
|
# Nextcloud group name for shop admins (default: "shop_admins")
|
||||||
|
NEXTCLOUD_ADMINS_GROUP="shop_admins"
|
||||||
|
|
||||||
# File Storage Configuration
|
# File Storage Configuration
|
||||||
# AWS S3 or Cloudflare R2 for file uploads
|
# AWS S3 or Cloudflare R2 for file uploads
|
||||||
AWS_ACCESS_KEY_ID="your-aws-access-key-id"
|
AWS_ACCESS_KEY_ID="your-aws-access-key-id"
|
||||||
|
|||||||
310
CLAUDE.md
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
United Tattoo is a Next.js-based website for a tattoo studio in Fountain, CO. The application includes artist portfolios, booking systems, appointment management with CalDAV integration, and admin dashboards.
|
||||||
|
|
||||||
|
**Stack:**
|
||||||
|
- Next.js 14 (App Router) with TypeScript
|
||||||
|
- Cloudflare D1 (SQLite) for database
|
||||||
|
- Cloudflare R2 for file storage
|
||||||
|
- NextAuth.js for authentication
|
||||||
|
- Deployed via OpenNext on Cloudflare Workers
|
||||||
|
- ShadCN UI components + Tailwind CSS
|
||||||
|
- Vitest for testing
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
### Development
|
||||||
|
```bash
|
||||||
|
npm run dev # Start Next.js dev server (port 3000)
|
||||||
|
npm run dev:wrangler # Build and preview with OpenNext/Cloudflare
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
```bash
|
||||||
|
npm run test # Run Vitest in watch mode
|
||||||
|
npm run test:ui # Run Vitest with UI
|
||||||
|
npm run test:run # Run tests once
|
||||||
|
npm run test:coverage # Run tests with coverage report
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build & Deployment
|
||||||
|
```bash
|
||||||
|
npm run pages:build # Build with OpenNext for Cloudflare
|
||||||
|
npm run build # Standard Next.js build (standalone)
|
||||||
|
npm run preview # Preview OpenNext build locally
|
||||||
|
npm run deploy # Deploy to Cloudflare Pages
|
||||||
|
```
|
||||||
|
|
||||||
|
### CI Commands
|
||||||
|
```bash
|
||||||
|
npm run ci:lint # ESLint
|
||||||
|
npm run ci:typecheck # TypeScript type checking (noEmit)
|
||||||
|
npm run ci:test # Run tests with coverage
|
||||||
|
npm run ci:build # Build for production
|
||||||
|
npm run ci:budgets # Check bundle size budgets
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Management
|
||||||
|
```bash
|
||||||
|
# Local database
|
||||||
|
npm run db:migrate:local # Apply schema to local D1
|
||||||
|
npm run db:studio:local # Show tables in local D1
|
||||||
|
|
||||||
|
# Preview (default) environment
|
||||||
|
npm run db:migrate # Apply schema to preview D1
|
||||||
|
npm run db:migrate:latest:preview # Apply all migrations from sql/migrations/
|
||||||
|
npm run db:studio # Show tables in preview D1
|
||||||
|
npm run db:backup # Backup preview database
|
||||||
|
|
||||||
|
# Production environment
|
||||||
|
npm run db:migrate:up:prod # Apply specific migration to production
|
||||||
|
npm run db:migrate:latest:prod # Apply all migrations to production
|
||||||
|
npm run db:backup:local # Backup local database
|
||||||
|
|
||||||
|
# Direct Wrangler commands
|
||||||
|
wrangler d1 execute united-tattoo --local --command="SELECT * FROM artists"
|
||||||
|
wrangler d1 execute united-tattoo --file=./sql/schema.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
```bash
|
||||||
|
npm run lint # Run ESLint
|
||||||
|
npm run format # Format code with Prettier
|
||||||
|
npm run format:check # Check formatting without changing files
|
||||||
|
npm run security:audit # Run npm audit
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Database Layer (`lib/db.ts`)
|
||||||
|
|
||||||
|
The database layer provides type-safe functions for interacting with Cloudflare D1. Key patterns:
|
||||||
|
|
||||||
|
- **Binding access**: `getDB(env)` retrieves D1 from Cloudflare bindings via OpenNext's global symbol
|
||||||
|
- **R2 access**: `getR2Bucket(env)` retrieves R2 bucket binding for file uploads
|
||||||
|
- **Namespace-style exports**: Use `db.artists.findMany()`, `db.portfolioImages.create()`, etc.
|
||||||
|
- **JSON fields**: `specialties` and `tags` are stored as JSON strings and parsed/stringified automatically
|
||||||
|
|
||||||
|
Main tables:
|
||||||
|
- `users` - Authentication and user profiles with roles (SUPER_ADMIN, SHOP_ADMIN, ARTIST, CLIENT)
|
||||||
|
- `artists` - Artist profiles linked to users, includes slug for URLs
|
||||||
|
- `portfolio_images` - Artist portfolio work with tags and ordering
|
||||||
|
- `appointments` - Booking appointments with CalDAV sync support
|
||||||
|
- `flash_items` - Flash tattoo designs available for booking
|
||||||
|
- `site_settings` - Global site configuration
|
||||||
|
- `artist_calendars` - Nextcloud CalDAV calendar configuration per artist
|
||||||
|
|
||||||
|
### Authentication (`lib/auth.ts`)
|
||||||
|
|
||||||
|
NextAuth.js setup with role-based access control and Nextcloud OAuth integration:
|
||||||
|
|
||||||
|
- **Primary Provider**: Nextcloud OAuth (recommended for all users)
|
||||||
|
- Artists and admins sign in via their Nextcloud credentials
|
||||||
|
- Auto-provisioning: Users in 'artists' or 'shop_admins' Nextcloud groups are automatically created
|
||||||
|
- Group-based role assignment:
|
||||||
|
- `admin` or `admins` group → SUPER_ADMIN
|
||||||
|
- `shop_admins` group (configurable) → SHOP_ADMIN
|
||||||
|
- `artists` group (configurable) → ARTIST (with auto-created artist profile)
|
||||||
|
- Users not in authorized groups are denied access
|
||||||
|
- Requires: `NEXTCLOUD_OAUTH_CLIENT_ID`, `NEXTCLOUD_OAUTH_CLIENT_SECRET`, `NEXTCLOUD_BASE_URL`
|
||||||
|
|
||||||
|
- **Fallback Provider**: Credentials (email/password)
|
||||||
|
- Available only via `/auth/signin?admin=true` query parameter
|
||||||
|
- Admin emergency access only
|
||||||
|
- Dev mode: Any email/password combo creates a SUPER_ADMIN user for testing
|
||||||
|
- Seed admin: `nicholai@biohazardvfx.com` is hardcoded as admin
|
||||||
|
|
||||||
|
- **Deprecated Providers**: Google/GitHub OAuth (still configured but not actively used)
|
||||||
|
|
||||||
|
- **Session strategy**: JWT (no database adapter currently)
|
||||||
|
|
||||||
|
- **Nextcloud Integration** (`lib/nextcloud-client.ts`):
|
||||||
|
- `getNextcloudUserProfile(userId)` - Fetch user details from Nextcloud OCS API
|
||||||
|
- `getNextcloudUserGroups(userId)` - Get user's group memberships
|
||||||
|
- `determineUserRole(userId)` - Auto-assign role based on Nextcloud groups
|
||||||
|
- Uses service account credentials (NEXTCLOUD_USERNAME/PASSWORD) for API access
|
||||||
|
|
||||||
|
- **Helper functions**:
|
||||||
|
- `requireAuth(role?)` - Protect routes, throws if unauthorized
|
||||||
|
- `getArtistSession()` - Get artist profile for logged-in artist users
|
||||||
|
- `canEditArtist(userId, artistId)` - Check edit permissions
|
||||||
|
- `hasRole(userRole, requiredRole)` - Check role hierarchy
|
||||||
|
|
||||||
|
### CalDAV Integration (`lib/calendar-sync.ts`, `lib/caldav-client.ts`)
|
||||||
|
|
||||||
|
Bidirectional sync between database appointments and Nextcloud calendars:
|
||||||
|
|
||||||
|
- **Push to calendar**: `syncAppointmentToCalendar()` - Called when creating/updating appointments
|
||||||
|
- **Pull from calendar**: `pullCalendarEventsToDatabase()` - Background sync to import calendar events
|
||||||
|
- **Availability checking**: `checkArtistAvailability()` - Real-time conflict detection using CalDAV
|
||||||
|
- **Per-artist calendars**: Each artist can have their own Nextcloud calendar configured in `artist_calendars` table
|
||||||
|
|
||||||
|
Environment variables required:
|
||||||
|
- `NEXTCLOUD_BASE_URL`
|
||||||
|
- `NEXTCLOUD_USERNAME`
|
||||||
|
- `NEXTCLOUD_PASSWORD`
|
||||||
|
- `NEXTCLOUD_CALENDAR_BASE_PATH` (defaults to `/remote.php/dav/calendars`)
|
||||||
|
|
||||||
|
### File Uploads (`lib/r2-upload.ts`, `lib/upload.ts`)
|
||||||
|
|
||||||
|
- **R2 storage**: Files uploaded to Cloudflare R2 bucket
|
||||||
|
- **Image processing**: HEIC to JPEG conversion, resizing, AVIF format support
|
||||||
|
- **Public URLs**: Files served from R2 public URL
|
||||||
|
- **Upload API**: `/api/upload` handles multipart form data
|
||||||
|
|
||||||
|
### Environment Configuration (`lib/env.ts`)
|
||||||
|
|
||||||
|
Validates required environment variables at boot using Zod. Critical vars:
|
||||||
|
- Database: `DATABASE_URL`, `DIRECT_URL` (Supabase URLs, though using D1)
|
||||||
|
- Auth: `NEXTAUTH_URL`, `NEXTAUTH_SECRET`
|
||||||
|
- Storage: AWS/R2 credentials (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_BUCKET_NAME`, `AWS_ENDPOINT_URL`)
|
||||||
|
- Nextcloud OAuth (required for artist authentication):
|
||||||
|
- `NEXTCLOUD_BASE_URL` - Nextcloud instance URL (e.g., https://portal.united-tattoos.com)
|
||||||
|
- `NEXTCLOUD_OAUTH_CLIENT_ID` - OAuth app client ID from Nextcloud
|
||||||
|
- `NEXTCLOUD_OAUTH_CLIENT_SECRET` - OAuth app client secret from Nextcloud
|
||||||
|
- `NEXTCLOUD_ARTISTS_GROUP` - Group name for artists (default: "artists")
|
||||||
|
- `NEXTCLOUD_ADMINS_GROUP` - Group name for admins (default: "shop_admins")
|
||||||
|
- Nextcloud CalDAV (optional, for calendar sync):
|
||||||
|
- `NEXTCLOUD_USERNAME` - Service account username
|
||||||
|
- `NEXTCLOUD_PASSWORD` - Service account password or app-specific password
|
||||||
|
- `NEXTCLOUD_CALENDAR_BASE_PATH` - CalDAV path (default: "/remote.php/dav/calendars")
|
||||||
|
|
||||||
|
Note: The env validation expects Supabase URLs but actual runtime uses Cloudflare D1 via bindings.
|
||||||
|
|
||||||
|
### API Routes
|
||||||
|
|
||||||
|
All API routes follow Next.js App Router conventions (`app/api/*/route.ts`):
|
||||||
|
|
||||||
|
**Public APIs:**
|
||||||
|
- `/api/artists` - List public artists with portfolio images
|
||||||
|
- `/api/artists/[id]` - Get single artist by ID or slug
|
||||||
|
- `/api/public/migrate` - Public migration endpoint (token-protected)
|
||||||
|
|
||||||
|
**Protected APIs** (require authentication):
|
||||||
|
- `/api/artists/me` - Current artist's profile (ARTIST role)
|
||||||
|
- `/api/portfolio` - CRUD for portfolio images
|
||||||
|
- `/api/flash/[artistId]` - Manage flash tattoo items
|
||||||
|
- `/api/appointments` - Appointment management
|
||||||
|
- `/api/upload` - File upload to R2
|
||||||
|
- `/api/admin/*` - Admin-only endpoints (stats, migrations, calendars)
|
||||||
|
- `/api/caldav/sync` - Trigger CalDAV sync
|
||||||
|
- `/api/caldav/availability` - Check artist availability
|
||||||
|
|
||||||
|
### Frontend Structure
|
||||||
|
|
||||||
|
**Pages:**
|
||||||
|
- `/` - Homepage (hero, artists, services, contact)
|
||||||
|
- `/artists` - Artist listing
|
||||||
|
- `/artists/[id]` - Individual artist portfolio (supports slug or ID)
|
||||||
|
- `/artists/[id]/book` - Book with specific artist
|
||||||
|
- `/book` - General booking page
|
||||||
|
- `/admin/*` - Admin dashboard (analytics, portfolio, calendar, artist management, uploads)
|
||||||
|
- `/artist-dashboard/*` - Artist-specific dashboard (profile, portfolio editing)
|
||||||
|
- `/auth/signin` - Login page
|
||||||
|
|
||||||
|
**Data Sources:**
|
||||||
|
- `data/artists.ts` - Static artist data (may be legacy, check if still used vs database)
|
||||||
|
|
||||||
|
### Routing Notes
|
||||||
|
|
||||||
|
- **Middleware** (`middleware.ts`): Handles permanent redirects (e.g., `/artists/amari-rodriguez` → `/artists/amari-kyss`)
|
||||||
|
- **Dynamic routes**: Artist pages work with both database IDs and slugs
|
||||||
|
- **Authentication**: Admin and artist dashboard routes require appropriate roles
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- **Framework**: Vitest with React Testing Library
|
||||||
|
- **Config**: `vitest.config.ts` (check for any custom setup)
|
||||||
|
- Tests located alongside components or in `__tests__/` directories
|
||||||
|
|
||||||
|
### Bundle Size Budgets
|
||||||
|
|
||||||
|
Defined in `package.json` under `budgets` key:
|
||||||
|
- `TOTAL_STATIC_MAX_BYTES`: 3,000,000 (3MB)
|
||||||
|
- `MAX_ASSET_BYTES`: 1,500,000 (1.5MB)
|
||||||
|
|
||||||
|
Checked by `scripts/budgets.mjs` during CI.
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### Working with Migrations
|
||||||
|
|
||||||
|
1. Create new migration file in `sql/migrations/` with format `YYYYMMDD_NNNN_description.sql`
|
||||||
|
2. For local testing: `npm run db:migrate:local`
|
||||||
|
3. For preview: `npm run db:migrate:latest:preview`
|
||||||
|
4. For production: `npm run db:migrate:latest:prod`
|
||||||
|
|
||||||
|
Migrations are also tracked in `sql/migrations_up/` for Wrangler's built-in migration system.
|
||||||
|
|
||||||
|
### Working with Artists
|
||||||
|
|
||||||
|
Artists have both a user account and an artist profile:
|
||||||
|
1. User created in `users` table with role `ARTIST`
|
||||||
|
2. Artist profile in `artists` table linked via `user_id`
|
||||||
|
3. Slug auto-generated from name, handles duplicates with numeric suffix
|
||||||
|
4. Portfolio images in `portfolio_images` table
|
||||||
|
5. Flash items in `flash_items` table (optional, new feature)
|
||||||
|
|
||||||
|
### Adding New Features
|
||||||
|
|
||||||
|
When adding database tables:
|
||||||
|
1. Add to `sql/schema.sql`
|
||||||
|
2. Create migration file in `sql/migrations/`
|
||||||
|
3. Update TypeScript types in `types/database.ts`
|
||||||
|
4. Add CRUD functions to `lib/db.ts`
|
||||||
|
5. Create API routes if needed
|
||||||
|
6. Update this CLAUDE.md if it's a major architectural change
|
||||||
|
|
||||||
|
### CI/CD Pipeline
|
||||||
|
|
||||||
|
Located in `.gitea/workflows/`:
|
||||||
|
- `ci.yaml` - Main CI pipeline (lint, typecheck, test, build, budgets)
|
||||||
|
- `security.yaml` - Security audits
|
||||||
|
- `performance.yaml` - Performance checks
|
||||||
|
- `deploy.yaml` - Deployment to Cloudflare
|
||||||
|
|
||||||
|
The CI enforces:
|
||||||
|
- ESLint passing
|
||||||
|
- TypeScript compilation (with `ignoreBuildErrors: true` for builds but strict for typecheck)
|
||||||
|
- Test coverage
|
||||||
|
- Bundle size budgets
|
||||||
|
- Migration dry-run (best-effort with local D1)
|
||||||
|
|
||||||
|
### Known Configuration Quirks
|
||||||
|
|
||||||
|
- **TypeScript errors ignored during build** (`next.config.mjs`): `typescript.ignoreBuildErrors: true` allows builds to succeed even with type errors. CI separately runs `tsc --noEmit` to catch them.
|
||||||
|
- **Images unoptimized**: Next.js Image optimization disabled for Cloudflare Workers compatibility
|
||||||
|
- **Standalone output**: Docker builds use `output: "standalone"` mode
|
||||||
|
- **Node.js compatibility**: Cloudflare Workers use `nodejs_compat` flag in `wrangler.toml`
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
|
||||||
|
Production URL: `https://united-tattoos.com`
|
||||||
|
|
||||||
|
Deploy command: `npm run pages:build && wrangler deploy`
|
||||||
|
|
||||||
|
The deployment process:
|
||||||
|
1. Build with OpenNext: `npm run pages:build` → outputs to `.vercel/output/static`
|
||||||
|
2. Deploy to Cloudflare: `wrangler pages deploy .vercel/output/static`
|
||||||
|
|
||||||
|
### Docker Support
|
||||||
|
|
||||||
|
Dockerfile included for self-hosting:
|
||||||
|
```bash
|
||||||
|
docker build -t united-tattoo:latest .
|
||||||
|
docker run --rm -p 3000:3000 -e PORT=3000 united-tattoo:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses Next.js standalone mode for minimal image size.
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
- Always test database changes locally first with `--local` flag
|
||||||
|
- The migration token (`MIGRATE_TOKEN`) protects public migration endpoints
|
||||||
|
- CalDAV integration is optional; appointments work without it
|
||||||
|
- Role hierarchy: CLIENT < ARTIST < SHOP_ADMIN < SUPER_ADMIN
|
||||||
|
- Flash items feature may not exist in older database schemas (lib/db.ts tolerates missing table)
|
||||||
@ -1,5 +1,7 @@
|
|||||||
# United Tattoo — Official Website (Next.js + ShadCN UI)
|
# United Tattoo — Official Website (Next.js + ShadCN UI)
|
||||||
|
|
||||||
|
# DEPLOYMENT COMMAND `npm run pages:build && wrangler deploy`
|
||||||
|
|
||||||
Hi, I’m Nicholai. I built this site for my friend Christy (aka Ink Mama) and the United Tattoo crew in Fountain, CO. The goal was simple: give the studio a site that actually reflects the art, the people, and the experience — not the stiff, generic stuff you usually see. This is also a thank you for everything Christy has done for Amari (my girlfriend and soulmate), who was her apprentice. So yeah, this is personal — and it shows.
|
Hi, I’m Nicholai. I built this site for my friend Christy (aka Ink Mama) and the United Tattoo crew in Fountain, CO. The goal was simple: give the studio a site that actually reflects the art, the people, and the experience — not the stiff, generic stuff you usually see. This is also a thank you for everything Christy has done for Amari (my girlfriend and soulmate), who was her apprentice. So yeah, this is personal — and it shows.
|
||||||
|
|
||||||
This repo powers the official United Tattoo website, built with:
|
This repo powers the official United Tattoo website, built with:
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { ThemeProvider } from "@/components/theme-provider"
|
|||||||
import type { FlagsSnapshot } from "@/lib/flags"
|
import type { FlagsSnapshot } from "@/lib/flags"
|
||||||
|
|
||||||
import "./globals.css"
|
import "./globals.css"
|
||||||
|
import ConstructionBanner from "@/components/construction-banner"
|
||||||
|
|
||||||
export default function ClientLayout({
|
export default function ClientLayout({
|
||||||
children,
|
children,
|
||||||
@ -52,7 +53,12 @@ export default function ClientLayout({
|
|||||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false}>
|
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false}>
|
||||||
<Suspense fallback={<div>Loading...</div>}>
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
<LenisProvider>
|
<LenisProvider>
|
||||||
{children}
|
{/* Global construction banner */}
|
||||||
|
<ConstructionBanner />
|
||||||
|
{/* Push fixed nav down when banner visible */}
|
||||||
|
<style>{`html.has-site-banner nav.fixed{top:var(--site-banner-height,0)!important}`}</style>
|
||||||
|
{/* Offset page content by banner height */}
|
||||||
|
<div style={{ paddingTop: "var(--site-banner-height, 0px)" }}>{children}</div>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</LenisProvider>
|
</LenisProvider>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
343
app/api/admin/calendars/route.ts
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
import { getDB } from '@/lib/db'
|
||||||
|
import { createCalDAVClient } from '@/lib/caldav-client'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
const createCalendarSchema = z.object({
|
||||||
|
artistId: z.string().min(1),
|
||||||
|
calendarUrl: z.string().url(),
|
||||||
|
calendarId: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateCalendarSchema = createCalendarSchema.partial().extend({
|
||||||
|
id: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/calendars
|
||||||
|
*
|
||||||
|
* Get all artist calendar configurations
|
||||||
|
* Admin only
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest, { params }: { params?: any } = {}, context?: any) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDB(context?.env)
|
||||||
|
|
||||||
|
// Check if user is admin
|
||||||
|
const user = await db
|
||||||
|
.prepare('SELECT role FROM users WHERE email = ?')
|
||||||
|
.bind(session.user.email)
|
||||||
|
.first()
|
||||||
|
|
||||||
|
if (!user || (user.role !== 'SUPER_ADMIN' && user.role !== 'SHOP_ADMIN')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all calendar configurations with artist info and last sync log
|
||||||
|
const calendars = await db
|
||||||
|
.prepare(`
|
||||||
|
SELECT
|
||||||
|
ac.*,
|
||||||
|
a.name as artist_name,
|
||||||
|
a.slug as artist_slug,
|
||||||
|
(
|
||||||
|
SELECT created_at
|
||||||
|
FROM calendar_sync_logs
|
||||||
|
WHERE artist_id = ac.artist_id
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) as last_sync_log_time,
|
||||||
|
(
|
||||||
|
SELECT status
|
||||||
|
FROM calendar_sync_logs
|
||||||
|
WHERE artist_id = ac.artist_id
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) as last_sync_status
|
||||||
|
FROM artist_calendars ac
|
||||||
|
INNER JOIN artists a ON ac.artist_id = a.id
|
||||||
|
ORDER BY a.name
|
||||||
|
`)
|
||||||
|
.all()
|
||||||
|
|
||||||
|
return NextResponse.json({ calendars: calendars.results })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching calendars:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch calendars' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/calendars
|
||||||
|
*
|
||||||
|
* Create a new artist calendar configuration
|
||||||
|
* Admin only
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest, { params }: { params?: any } = {}, context?: any) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDB(context?.env)
|
||||||
|
|
||||||
|
// Check if user is admin
|
||||||
|
const user = await db
|
||||||
|
.prepare('SELECT role FROM users WHERE email = ?')
|
||||||
|
.bind(session.user.email)
|
||||||
|
.first()
|
||||||
|
|
||||||
|
if (!user || (user.role !== 'SUPER_ADMIN' && user.role !== 'SHOP_ADMIN')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const validatedData = createCalendarSchema.parse(body)
|
||||||
|
|
||||||
|
// Check if artist exists
|
||||||
|
const artist = await db
|
||||||
|
.prepare('SELECT id FROM artists WHERE id = ?')
|
||||||
|
.bind(validatedData.artistId)
|
||||||
|
.first()
|
||||||
|
|
||||||
|
if (!artist) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Artist not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if calendar config already exists for this artist
|
||||||
|
const existing = await db
|
||||||
|
.prepare('SELECT id FROM artist_calendars WHERE artist_id = ?')
|
||||||
|
.bind(validatedData.artistId)
|
||||||
|
.first()
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Calendar configuration already exists for this artist' },
|
||||||
|
{ status: 409 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test calendar connection
|
||||||
|
const client = createCalDAVClient()
|
||||||
|
if (client) {
|
||||||
|
try {
|
||||||
|
await client.login()
|
||||||
|
// Try to fetch calendars to verify connection
|
||||||
|
await client.fetchCalendars()
|
||||||
|
} catch (testError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to connect to CalDAV server. Please check your credentials and calendar URL.' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create calendar configuration
|
||||||
|
const calendarId = crypto.randomUUID()
|
||||||
|
await db
|
||||||
|
.prepare(`
|
||||||
|
INSERT INTO artist_calendars (
|
||||||
|
id, artist_id, calendar_url, calendar_id, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
`)
|
||||||
|
.bind(
|
||||||
|
calendarId,
|
||||||
|
validatedData.artistId,
|
||||||
|
validatedData.calendarUrl,
|
||||||
|
validatedData.calendarId
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
|
||||||
|
// Fetch the created configuration
|
||||||
|
const calendar = await db
|
||||||
|
.prepare('SELECT * FROM artist_calendars WHERE id = ?')
|
||||||
|
.bind(calendarId)
|
||||||
|
.first()
|
||||||
|
|
||||||
|
return NextResponse.json({ calendar }, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating calendar:', error)
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid calendar data', details: error.errors },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to create calendar configuration' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/admin/calendars
|
||||||
|
*
|
||||||
|
* Update an artist calendar configuration
|
||||||
|
* Admin only
|
||||||
|
*/
|
||||||
|
export async function PUT(request: NextRequest, { params }: { params?: any } = {}, context?: any) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDB(context?.env)
|
||||||
|
|
||||||
|
// Check if user is admin
|
||||||
|
const user = await db
|
||||||
|
.prepare('SELECT role FROM users WHERE email = ?')
|
||||||
|
.bind(session.user.email)
|
||||||
|
.first()
|
||||||
|
|
||||||
|
if (!user || (user.role !== 'SUPER_ADMIN' && user.role !== 'SHOP_ADMIN')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const validatedData = updateCalendarSchema.parse(body)
|
||||||
|
|
||||||
|
// Check if calendar exists
|
||||||
|
const existing = await db
|
||||||
|
.prepare('SELECT * FROM artist_calendars WHERE id = ?')
|
||||||
|
.bind(validatedData.id)
|
||||||
|
.first()
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Calendar configuration not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build update query
|
||||||
|
const updateFields = []
|
||||||
|
const updateValues = []
|
||||||
|
|
||||||
|
if (validatedData.calendarUrl) {
|
||||||
|
updateFields.push('calendar_url = ?')
|
||||||
|
updateValues.push(validatedData.calendarUrl)
|
||||||
|
}
|
||||||
|
if (validatedData.calendarId) {
|
||||||
|
updateFields.push('calendar_id = ?')
|
||||||
|
updateValues.push(validatedData.calendarId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No fields to update' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push('updated_at = CURRENT_TIMESTAMP')
|
||||||
|
updateValues.push(validatedData.id)
|
||||||
|
|
||||||
|
await db
|
||||||
|
.prepare(`
|
||||||
|
UPDATE artist_calendars
|
||||||
|
SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = ?
|
||||||
|
`)
|
||||||
|
.bind(...updateValues)
|
||||||
|
.run()
|
||||||
|
|
||||||
|
// Fetch updated configuration
|
||||||
|
const calendar = await db
|
||||||
|
.prepare('SELECT * FROM artist_calendars WHERE id = ?')
|
||||||
|
.bind(validatedData.id)
|
||||||
|
.first()
|
||||||
|
|
||||||
|
return NextResponse.json({ calendar })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating calendar:', error)
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid calendar data', details: error.errors },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to update calendar configuration' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/admin/calendars
|
||||||
|
*
|
||||||
|
* Delete an artist calendar configuration
|
||||||
|
* Admin only
|
||||||
|
*/
|
||||||
|
export async function DELETE(request: NextRequest, { params }: { params?: any } = {}, context?: any) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDB(context?.env)
|
||||||
|
|
||||||
|
// Check if user is admin
|
||||||
|
const user = await db
|
||||||
|
.prepare('SELECT role FROM users WHERE email = ?')
|
||||||
|
.bind(session.user.email)
|
||||||
|
.first()
|
||||||
|
|
||||||
|
if (!user || (user.role !== 'SUPER_ADMIN' && user.role !== 'SHOP_ADMIN')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const id = searchParams.get('id')
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Calendar ID is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteStmt = db.prepare('DELETE FROM artist_calendars WHERE id = ?')
|
||||||
|
const result = await deleteStmt.bind(id).run()
|
||||||
|
|
||||||
|
const written = (result as any)?.meta?.changes ?? (result as any)?.meta?.rows_written ?? 0
|
||||||
|
if (written === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Calendar configuration not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting calendar:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to delete calendar configuration' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -3,6 +3,11 @@ import { getServerSession } from 'next-auth'
|
|||||||
import { authOptions } from '@/lib/auth'
|
import { authOptions } from '@/lib/auth'
|
||||||
import { getDB } from '@/lib/db'
|
import { getDB } from '@/lib/db'
|
||||||
import { Flags } from '@/lib/flags'
|
import { Flags } from '@/lib/flags'
|
||||||
|
import {
|
||||||
|
syncAppointmentToCalendar,
|
||||||
|
deleteAppointmentFromCalendar,
|
||||||
|
checkArtistAvailability,
|
||||||
|
} from '@/lib/calendar-sync'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@ -103,33 +108,30 @@ export async function POST(request: NextRequest, { params }: { params?: any } =
|
|||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const validatedData = createAppointmentSchema.parse(body)
|
const validatedData = createAppointmentSchema.parse(body)
|
||||||
|
|
||||||
// Check for scheduling conflicts
|
|
||||||
const db = getDB(context?.env)
|
const db = getDB(context?.env)
|
||||||
const conflictCheck = db.prepare(`
|
|
||||||
SELECT id FROM appointments
|
|
||||||
WHERE artist_id = ?
|
|
||||||
AND status NOT IN ('CANCELLED', 'COMPLETED')
|
|
||||||
AND (
|
|
||||||
(start_time <= ? AND end_time > ?) OR
|
|
||||||
(start_time < ? AND end_time >= ?) OR
|
|
||||||
(start_time >= ? AND end_time <= ?)
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
|
|
||||||
const conflictResult = await conflictCheck.bind(
|
// IMPORTANT: Check CalDAV availability first (Nextcloud is source of truth)
|
||||||
|
const startDate = new Date(validatedData.startTime)
|
||||||
|
const endDate = new Date(validatedData.endTime)
|
||||||
|
|
||||||
|
const availabilityCheck = await checkArtistAvailability(
|
||||||
validatedData.artistId,
|
validatedData.artistId,
|
||||||
validatedData.startTime, validatedData.startTime,
|
startDate,
|
||||||
validatedData.endTime, validatedData.endTime,
|
endDate,
|
||||||
validatedData.startTime, validatedData.endTime
|
context
|
||||||
).all()
|
)
|
||||||
|
|
||||||
if (conflictResult.results.length > 0) {
|
if (!availabilityCheck.available) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Time slot conflicts with existing appointment' },
|
{
|
||||||
|
error: 'Time slot not available',
|
||||||
|
reason: availabilityCheck.reason || 'Selected time slot conflicts with existing booking. Please select a different time.'
|
||||||
|
},
|
||||||
{ status: 409 }
|
{ status: 409 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create appointment in database with PENDING status
|
||||||
const appointmentId = crypto.randomUUID()
|
const appointmentId = crypto.randomUUID()
|
||||||
const insertStmt = db.prepare(`
|
const insertStmt = db.prepare(`
|
||||||
INSERT INTO appointments (
|
INSERT INTO appointments (
|
||||||
@ -166,6 +168,14 @@ export async function POST(request: NextRequest, { params }: { params?: any } =
|
|||||||
|
|
||||||
const appointment = await selectStmt.bind(appointmentId).first()
|
const appointment = await selectStmt.bind(appointmentId).first()
|
||||||
|
|
||||||
|
// Sync to CalDAV calendar (non-blocking - failure won't prevent appointment creation)
|
||||||
|
try {
|
||||||
|
await syncAppointmentToCalendar(appointment as any, context)
|
||||||
|
} catch (syncError) {
|
||||||
|
console.error('Failed to sync appointment to calendar:', syncError)
|
||||||
|
// Continue - appointment is created in DB even if CalDAV sync fails
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ appointment }, { status: 201 })
|
return NextResponse.json({ appointment }, { status: 201 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating appointment:', error)
|
console.error('Error creating appointment:', error)
|
||||||
@ -286,6 +296,14 @@ export async function PUT(request: NextRequest, { params }: { params?: any } = {
|
|||||||
|
|
||||||
const appointment = await selectStmt.bind(validatedData.id).first()
|
const appointment = await selectStmt.bind(validatedData.id).first()
|
||||||
|
|
||||||
|
// Sync updated appointment to CalDAV (non-blocking)
|
||||||
|
try {
|
||||||
|
await syncAppointmentToCalendar(appointment as any, context)
|
||||||
|
} catch (syncError) {
|
||||||
|
console.error('Failed to sync updated appointment to calendar:', syncError)
|
||||||
|
// Continue - appointment is updated in DB even if CalDAV sync fails
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ appointment })
|
return NextResponse.json({ appointment })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating appointment:', error)
|
console.error('Error updating appointment:', error)
|
||||||
@ -323,17 +341,29 @@ export async function DELETE(request: NextRequest, { params }: { params?: any }
|
|||||||
}
|
}
|
||||||
|
|
||||||
const db = getDB(context?.env)
|
const db = getDB(context?.env)
|
||||||
const deleteStmt = db.prepare('DELETE FROM appointments WHERE id = ?')
|
|
||||||
const result = await deleteStmt.bind(id).run()
|
// Fetch appointment before deleting (needed for CalDAV sync)
|
||||||
|
const appointment = await db.prepare('SELECT * FROM appointments WHERE id = ?').bind(id).first()
|
||||||
const written = (result as any)?.meta?.changes ?? (result as any)?.meta?.rows_written ?? 0
|
|
||||||
if (written === 0) {
|
if (!appointment) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Appointment not found' },
|
{ error: 'Appointment not found' },
|
||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete from CalDAV calendar first (non-blocking)
|
||||||
|
try {
|
||||||
|
await deleteAppointmentFromCalendar(appointment as any, context)
|
||||||
|
} catch (syncError) {
|
||||||
|
console.error('Failed to delete appointment from calendar:', syncError)
|
||||||
|
// Continue with DB deletion even if CalDAV deletion fails
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from database
|
||||||
|
const deleteStmt = db.prepare('DELETE FROM appointments WHERE id = ?')
|
||||||
|
await deleteStmt.bind(id).run()
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting appointment:', error)
|
console.error('Error deleting appointment:', error)
|
||||||
|
|||||||
55
app/api/auth/nextcloud/authorize/route.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom Nextcloud OAuth Authorization Handler
|
||||||
|
*
|
||||||
|
* This route initiates the OAuth flow by redirecting to Nextcloud's authorization endpoint.
|
||||||
|
* Uses native fetch API instead of NextAuth's OAuth provider (which doesn't work in Cloudflare Workers).
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const baseUrl = process.env.NEXTCLOUD_BASE_URL
|
||||||
|
const clientId = process.env.NEXTCLOUD_OAUTH_CLIENT_ID
|
||||||
|
|
||||||
|
if (!baseUrl || !clientId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Nextcloud OAuth is not configured' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get callback URL from request or use default
|
||||||
|
const callbackUrl = request.nextUrl.searchParams.get('callbackUrl') || '/admin'
|
||||||
|
|
||||||
|
// Generate random state for CSRF protection
|
||||||
|
const state = crypto.randomUUID()
|
||||||
|
|
||||||
|
// Store state and callback URL in cookies
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
cookieStore.set('nextcloud_oauth_state', state, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 600, // 10 minutes
|
||||||
|
path: '/',
|
||||||
|
})
|
||||||
|
|
||||||
|
cookieStore.set('nextcloud_oauth_callback', callbackUrl, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 600,
|
||||||
|
path: '/',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build authorization URL
|
||||||
|
const authUrl = new URL(`${baseUrl}/index.php/apps/oauth2/authorize`)
|
||||||
|
authUrl.searchParams.set('client_id', clientId)
|
||||||
|
authUrl.searchParams.set('response_type', 'code')
|
||||||
|
authUrl.searchParams.set('redirect_uri', `${process.env.NEXTAUTH_URL}/api/auth/nextcloud/callback`)
|
||||||
|
authUrl.searchParams.set('state', state)
|
||||||
|
authUrl.searchParams.set('scope', 'openid profile email')
|
||||||
|
|
||||||
|
// Redirect to Nextcloud
|
||||||
|
return NextResponse.redirect(authUrl.toString())
|
||||||
|
}
|
||||||
194
app/api/auth/nextcloud/callback/route.ts
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { signIn } from 'next-auth/react'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
import { determineUserRole, getNextcloudUserProfile } from '@/lib/nextcloud-client'
|
||||||
|
import { getUserByEmail, createUser, createArtist } from '@/lib/db'
|
||||||
|
import { UserRole } from '@/types/database'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom Nextcloud OAuth Callback Handler
|
||||||
|
*
|
||||||
|
* Handles the OAuth callback from Nextcloud, exchanges code for token,
|
||||||
|
* fetches user info, auto-provisions users/artists, and creates a session.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const searchParams = request.nextUrl.searchParams
|
||||||
|
const code = searchParams.get('code')
|
||||||
|
const state = searchParams.get('state')
|
||||||
|
const error = searchParams.get('error')
|
||||||
|
|
||||||
|
// Handle OAuth errors
|
||||||
|
if (error) {
|
||||||
|
console.error('OAuth error:', error)
|
||||||
|
return NextResponse.redirect(
|
||||||
|
`${process.env.NEXTAUTH_URL}/auth/signin?error=OAuthSignin`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code || !state) {
|
||||||
|
return NextResponse.redirect(
|
||||||
|
`${process.env.NEXTAUTH_URL}/auth/signin?error=OAuthSignin`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate state (CSRF protection)
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
const storedState = cookieStore.get('nextcloud_oauth_state')?.value
|
||||||
|
const callbackUrl = cookieStore.get('nextcloud_oauth_callback')?.value || '/admin'
|
||||||
|
|
||||||
|
if (!storedState || storedState !== state) {
|
||||||
|
console.error('State mismatch - possible CSRF attack')
|
||||||
|
return NextResponse.redirect(
|
||||||
|
`${process.env.NEXTAUTH_URL}/auth/signin?error=OAuthSignin`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear state cookies
|
||||||
|
cookieStore.delete('nextcloud_oauth_state')
|
||||||
|
cookieStore.delete('nextcloud_oauth_callback')
|
||||||
|
|
||||||
|
const baseUrl = process.env.NEXTCLOUD_BASE_URL
|
||||||
|
const clientId = process.env.NEXTCLOUD_OAUTH_CLIENT_ID
|
||||||
|
const clientSecret = process.env.NEXTCLOUD_OAUTH_CLIENT_SECRET
|
||||||
|
|
||||||
|
if (!baseUrl || !clientId || !clientSecret) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Nextcloud OAuth is not configured' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Exchange authorization code for access token
|
||||||
|
const tokenUrl = `${baseUrl}/index.php/apps/oauth2/api/v1/token`
|
||||||
|
const tokenResponse = await fetch(tokenUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
redirect_uri: `${process.env.NEXTAUTH_URL}/api/auth/nextcloud/callback`,
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!tokenResponse.ok) {
|
||||||
|
const errorText = await tokenResponse.text()
|
||||||
|
console.error('Token exchange failed:', errorText)
|
||||||
|
return NextResponse.redirect(
|
||||||
|
`${process.env.NEXTAUTH_URL}/auth/signin?error=OAuthSignin`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenData = await tokenResponse.json()
|
||||||
|
const accessToken = tokenData.access_token
|
||||||
|
|
||||||
|
// Step 2: Fetch user info from Nextcloud
|
||||||
|
const userInfoUrl = `${baseUrl}/ocs/v2.php/cloud/user?format=json`
|
||||||
|
const userInfoResponse = await fetch(userInfoUrl, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'OCS-APIRequest': 'true',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!userInfoResponse.ok) {
|
||||||
|
console.error('Failed to fetch user info')
|
||||||
|
return NextResponse.redirect(
|
||||||
|
`${process.env.NEXTAUTH_URL}/auth/signin?error=OAuthSignin`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const userInfoData = await userInfoResponse.json()
|
||||||
|
const userData = userInfoData.ocs?.data
|
||||||
|
|
||||||
|
if (!userData) {
|
||||||
|
console.error('Invalid user info response')
|
||||||
|
return NextResponse.redirect(
|
||||||
|
`${process.env.NEXTAUTH_URL}/auth/signin?error=OAuthSignin`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = userData.id
|
||||||
|
const email = userData.email
|
||||||
|
const displayName = userData.displayname || userData['display-name'] || userId
|
||||||
|
|
||||||
|
console.log(`Nextcloud user authenticated: ${email} (${userId})`)
|
||||||
|
|
||||||
|
// Step 3: Determine role from Nextcloud groups
|
||||||
|
const role = await determineUserRole(userId)
|
||||||
|
|
||||||
|
// Prevent non-authorized users from signing in
|
||||||
|
if (role === UserRole.CLIENT) {
|
||||||
|
console.warn(`User ${email} is not in an authorized group`)
|
||||||
|
return NextResponse.redirect(
|
||||||
|
`${process.env.NEXTAUTH_URL}/auth/signin?error=OAuthSignin`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Auto-provision user if needed
|
||||||
|
let user = await getUserByEmail(email)
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.log(`Creating new user for ${email} with role ${role}`)
|
||||||
|
|
||||||
|
user = await createUser({
|
||||||
|
email,
|
||||||
|
name: displayName,
|
||||||
|
role,
|
||||||
|
})
|
||||||
|
|
||||||
|
// If artist, create artist profile
|
||||||
|
if (role === UserRole.ARTIST) {
|
||||||
|
const artist = await createArtist({
|
||||||
|
userId: user.id,
|
||||||
|
name: displayName,
|
||||||
|
bio: '',
|
||||||
|
specialties: [],
|
||||||
|
instagramHandle: null,
|
||||||
|
hourlyRate: null,
|
||||||
|
isActive: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Created artist profile ${artist.id}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`Existing user ${email} signed in`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Create a one-time token for session completion
|
||||||
|
// Store user ID in a short-lived cookie that credentials provider will validate
|
||||||
|
const oneTimeToken = crypto.randomUUID()
|
||||||
|
|
||||||
|
cookieStore.set('nextcloud_user_id', user.id, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 60, // 1 minute - just long enough to complete sign-in
|
||||||
|
path: '/',
|
||||||
|
})
|
||||||
|
|
||||||
|
cookieStore.set('nextcloud_one_time_token', oneTimeToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 60,
|
||||||
|
path: '/',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Redirect to completion page that will auto-submit to NextAuth
|
||||||
|
return NextResponse.redirect(
|
||||||
|
`${process.env.NEXTAUTH_URL}/auth/nextcloud/complete?token=${oneTimeToken}&callbackUrl=${encodeURIComponent(callbackUrl)}`
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('OAuth callback error:', error)
|
||||||
|
return NextResponse.redirect(
|
||||||
|
`${process.env.NEXTAUTH_URL}/auth/signin?error=OAuthSignin`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
72
app/api/caldav/availability/route.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { checkArtistAvailability } from '@/lib/calendar-sync'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
const availabilitySchema = z.object({
|
||||||
|
artistId: z.string().min(1),
|
||||||
|
startTime: z.string().datetime(),
|
||||||
|
endTime: z.string().datetime(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/caldav/availability
|
||||||
|
*
|
||||||
|
* Check availability for an artist at a specific time slot
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - artistId: string
|
||||||
|
* - startTime: ISO datetime string
|
||||||
|
* - endTime: ISO datetime string
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest, { params }: { params?: any } = {}, context?: any) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
|
||||||
|
const artistId = searchParams.get('artistId')
|
||||||
|
const startTime = searchParams.get('startTime')
|
||||||
|
const endTime = searchParams.get('endTime')
|
||||||
|
|
||||||
|
// Validate inputs
|
||||||
|
const validatedData = availabilitySchema.parse({
|
||||||
|
artistId,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
})
|
||||||
|
|
||||||
|
const startDate = new Date(validatedData.startTime)
|
||||||
|
const endDate = new Date(validatedData.endTime)
|
||||||
|
|
||||||
|
// Check availability (checks both CalDAV and database)
|
||||||
|
const result = await checkArtistAvailability(
|
||||||
|
validatedData.artistId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
context
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
artistId: validatedData.artistId,
|
||||||
|
startTime: validatedData.startTime,
|
||||||
|
endTime: validatedData.endTime,
|
||||||
|
available: result.available,
|
||||||
|
reason: result.reason,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking availability:', error)
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid request parameters', details: error.errors },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to check availability' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
160
app/api/caldav/sync/route.ts
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
import { getDB } from '@/lib/db'
|
||||||
|
import { pullCalendarEventsToDatabase, logSync } from '@/lib/calendar-sync'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
const syncSchema = z.object({
|
||||||
|
artistId: z.string().min(1).optional(),
|
||||||
|
startDate: z.string().datetime().optional(),
|
||||||
|
endDate: z.string().datetime().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/caldav/sync
|
||||||
|
*
|
||||||
|
* Manually trigger calendar sync from Nextcloud to database
|
||||||
|
* Admin only endpoint
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - artistId?: string (if omitted, syncs all artists)
|
||||||
|
* - startDate?: ISO datetime (defaults to 30 days ago)
|
||||||
|
* - endDate?: ISO datetime (defaults to 90 days from now)
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest, { params }: { params?: any } = {}, context?: any) {
|
||||||
|
try {
|
||||||
|
// Check authentication and authorization
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDB(context?.env)
|
||||||
|
|
||||||
|
// Check if user is admin
|
||||||
|
const user = await db
|
||||||
|
.prepare('SELECT role FROM users WHERE email = ?')
|
||||||
|
.bind(session.user.email)
|
||||||
|
.first()
|
||||||
|
|
||||||
|
if (!user || (user.role !== 'SUPER_ADMIN' && user.role !== 'SHOP_ADMIN')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden: Admin access required' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const validatedData = syncSchema.parse(body)
|
||||||
|
|
||||||
|
// Set default date range
|
||||||
|
const startDate = validatedData.startDate
|
||||||
|
? new Date(validatedData.startDate)
|
||||||
|
: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) // 30 days ago
|
||||||
|
|
||||||
|
const endDate = validatedData.endDate
|
||||||
|
? new Date(validatedData.endDate)
|
||||||
|
: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days from now
|
||||||
|
|
||||||
|
// Get artists to sync
|
||||||
|
let artistsToSync: any[] = []
|
||||||
|
|
||||||
|
if (validatedData.artistId) {
|
||||||
|
const artist = await db
|
||||||
|
.prepare('SELECT id FROM artists WHERE id = ?')
|
||||||
|
.bind(validatedData.artistId)
|
||||||
|
.first()
|
||||||
|
|
||||||
|
if (artist) {
|
||||||
|
artistsToSync = [artist]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Get all artists with calendar configurations
|
||||||
|
const artists = await db
|
||||||
|
.prepare(`
|
||||||
|
SELECT DISTINCT a.id
|
||||||
|
FROM artists a
|
||||||
|
INNER JOIN artist_calendars ac ON a.id = ac.artist_id
|
||||||
|
WHERE a.is_active = TRUE
|
||||||
|
`)
|
||||||
|
.all()
|
||||||
|
|
||||||
|
artistsToSync = artists.results
|
||||||
|
}
|
||||||
|
|
||||||
|
if (artistsToSync.length === 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'No artists with calendar configurations found',
|
||||||
|
synced: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform sync for each artist
|
||||||
|
const syncResults = []
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
for (const artist of artistsToSync) {
|
||||||
|
const artistStartTime = Date.now()
|
||||||
|
|
||||||
|
const result = await pullCalendarEventsToDatabase(
|
||||||
|
artist.id,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
context
|
||||||
|
)
|
||||||
|
|
||||||
|
const duration = Date.now() - artistStartTime
|
||||||
|
|
||||||
|
// Log the sync operation
|
||||||
|
await logSync({
|
||||||
|
artistId: artist.id,
|
||||||
|
syncType: 'PULL',
|
||||||
|
status: result.success ? 'SUCCESS' : 'FAILED',
|
||||||
|
errorMessage: result.error,
|
||||||
|
eventsProcessed: result.eventsProcessed,
|
||||||
|
eventsCreated: result.eventsCreated,
|
||||||
|
eventsUpdated: result.eventsUpdated,
|
||||||
|
eventsDeleted: result.eventsDeleted,
|
||||||
|
durationMs: duration,
|
||||||
|
}, context)
|
||||||
|
|
||||||
|
syncResults.push({
|
||||||
|
artistId: artist.id,
|
||||||
|
...result,
|
||||||
|
durationMs: duration,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalDuration = Date.now() - startTime
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: 'Sync completed',
|
||||||
|
totalArtists: artistsToSync.length,
|
||||||
|
totalDurationMs: totalDuration,
|
||||||
|
results: syncResults,
|
||||||
|
summary: {
|
||||||
|
totalEventsProcessed: syncResults.reduce((sum, r) => sum + r.eventsProcessed, 0),
|
||||||
|
totalEventsCreated: syncResults.reduce((sum, r) => sum + r.eventsCreated, 0),
|
||||||
|
totalEventsUpdated: syncResults.reduce((sum, r) => sum + r.eventsUpdated, 0),
|
||||||
|
totalEventsDeleted: syncResults.reduce((sum, r) => sum + r.eventsDeleted, 0),
|
||||||
|
successCount: syncResults.filter(r => r.success).length,
|
||||||
|
failureCount: syncResults.filter(r => !r.success).length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during sync:', error)
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid request parameters', details: error.errors },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Sync failed', message: error instanceof Error ? error.message : 'Unknown error' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
22
app/api/flash/[artistId]/route.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { getDB } from '@/lib/db'
|
||||||
|
|
||||||
|
export async function GET(_req: NextRequest, { params }: { params: { artistId: string } }) {
|
||||||
|
try {
|
||||||
|
const db = getDB()
|
||||||
|
const result = await db.prepare(`
|
||||||
|
SELECT * FROM flash_items
|
||||||
|
WHERE artist_id = ? AND is_available = 1
|
||||||
|
ORDER BY order_index ASC, created_at DESC
|
||||||
|
`).bind(params.artistId).all()
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ items: result.results }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
return new Response(JSON.stringify({ error: err?.message || 'Failed to fetch flash items' }), { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
24
app/api/flash/item/[id]/route.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { getDB } from '@/lib/db'
|
||||||
|
|
||||||
|
export async function GET(_req: NextRequest, { params }: { params: { id: string } }) {
|
||||||
|
try {
|
||||||
|
const db = getDB()
|
||||||
|
const result = await db.prepare(`
|
||||||
|
SELECT * FROM flash_items WHERE id = ?
|
||||||
|
`).bind(params.id).first()
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ item: result }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
return new Response(JSON.stringify({ error: err?.message || 'Failed to fetch flash item' }), { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
58
app/auth/nextcloud/complete/page.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect } from "react"
|
||||||
|
import { signIn } from "next-auth/react"
|
||||||
|
import { useSearchParams } from "next/navigation"
|
||||||
|
import { Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nextcloud OAuth Completion Page
|
||||||
|
*
|
||||||
|
* This page automatically completes the NextAuth sign-in after successful OAuth.
|
||||||
|
* It receives a one-time token and submits it to the credentials provider.
|
||||||
|
*/
|
||||||
|
export default function NextcloudCompletePage() {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const token = searchParams.get("token")
|
||||||
|
const callbackUrl = searchParams.get("callbackUrl") || "/admin"
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
// No token, redirect to sign-in
|
||||||
|
window.location.href = "/auth/signin?error=SessionError"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-submit to NextAuth credentials provider
|
||||||
|
const completeSignIn = async () => {
|
||||||
|
try {
|
||||||
|
const result = await signIn("credentials", {
|
||||||
|
nextcloud_token: token,
|
||||||
|
redirect: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
console.error("Sign-in error:", result.error)
|
||||||
|
window.location.href = "/auth/signin?error=SessionError"
|
||||||
|
} else if (result?.ok) {
|
||||||
|
// Success! Redirect to callback URL
|
||||||
|
window.location.href = callbackUrl
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Unexpected error during sign-in:", error)
|
||||||
|
window.location.href = "/auth/signin?error=SessionError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
completeSignIn()
|
||||||
|
}, [token, callbackUrl])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<Loader2 className="h-12 w-12 animate-spin mx-auto text-gray-600" />
|
||||||
|
<p className="mt-4 text-gray-600">Completing sign-in...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -8,16 +8,17 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||||
import { Loader2 } from "lucide-react"
|
import { Loader2, Cloud } from "lucide-react"
|
||||||
|
|
||||||
export default function SignInPage() {
|
export default function SignInPage() {
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const urlError = searchParams.get("error")
|
const urlError = searchParams.get("error")
|
||||||
const callbackUrl = searchParams.get("callbackUrl") || "/admin"
|
const callbackUrl = searchParams.get("callbackUrl") || "/admin"
|
||||||
|
const showAdminLogin = searchParams.get("admin") === "true"
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@ -49,71 +50,116 @@ export default function SignInPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleNextcloudSignIn = () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
// Redirect to custom OAuth authorization route
|
||||||
|
window.location.href = `/api/auth/nextcloud/authorize?callbackUrl=${encodeURIComponent(callbackUrl)}`
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
<CardTitle className="text-2xl font-bold">Sign In</CardTitle>
|
<CardTitle className="text-2xl font-bold">Sign In</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Access the United Tattoo Studio admin dashboard
|
{showAdminLogin
|
||||||
|
? "Admin emergency access"
|
||||||
|
: "Access the United Tattoo Studio dashboard"}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{(error || urlError) && (
|
{(error || urlError) && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{error || (urlError === "CredentialsSignin"
|
{error ||
|
||||||
? "Invalid email or password. Please try again."
|
(urlError === "CredentialsSignin"
|
||||||
: "An error occurred during sign in. Please try again."
|
? "Invalid email or password. Please try again."
|
||||||
)}
|
: urlError === "OAuthSignin"
|
||||||
|
? "Unable to sign in with Nextcloud. Please ensure you are in the 'artists' or 'shop_admins' group."
|
||||||
|
: "An error occurred during sign in. Please try again.")}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Credentials Form */}
|
{!showAdminLogin ? (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<>
|
||||||
<div className="space-y-2">
|
{/* Nextcloud OAuth Primary Sign In */}
|
||||||
<Label htmlFor="email">Email</Label>
|
<Button
|
||||||
<Input
|
onClick={handleNextcloudSignIn}
|
||||||
id="email"
|
className="w-full"
|
||||||
name="email"
|
size="lg"
|
||||||
type="email"
|
|
||||||
placeholder="nicholai@biohazardvfx.com"
|
|
||||||
required
|
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
>
|
||||||
</div>
|
{isLoading ? (
|
||||||
<div className="space-y-2">
|
<>
|
||||||
<Label htmlFor="password">Password</Label>
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
<Input
|
Signing in...
|
||||||
id="password"
|
</>
|
||||||
name="password"
|
) : (
|
||||||
type="password"
|
<>
|
||||||
placeholder="Enter your password"
|
<Cloud className="mr-2 h-5 w-5" />
|
||||||
required
|
Sign in with Nextcloud
|
||||||
disabled={isLoading}
|
</>
|
||||||
/>
|
)}
|
||||||
</div>
|
</Button>
|
||||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
|
||||||
{isLoading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
Signing in...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Sign In"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Development Note */}
|
{/* Info Text */}
|
||||||
<div className="text-center text-sm text-gray-500">
|
<div className="text-center text-sm text-gray-600">
|
||||||
<p>For development testing:</p>
|
<p>Use your Nextcloud credentials to sign in.</p>
|
||||||
<p className="text-xs mt-1">
|
<p className="text-xs mt-2 text-gray-500">
|
||||||
Use any email/password combination.<br />
|
You must be a member of the 'artists' or
|
||||||
Admin: nicholai@biohazardvfx.com
|
'shop_admins' group.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Credentials Form (Admin Fallback) */}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="nicholai@biohazardvfx.com"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Signing in...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Sign In"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Development Note */}
|
||||||
|
<div className="text-center text-sm text-gray-500">
|
||||||
|
<p className="text-xs">
|
||||||
|
Admin emergency access only.<br />
|
||||||
|
For normal sign in, use Nextcloud OAuth.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,10 +3,13 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react"
|
import { useState, useEffect, useRef, useCallback } from "react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, Instagram, ExternalLink, Loader2, DollarSign } from "lucide-react"
|
import { Instagram, ExternalLink, Loader2 } from "lucide-react"
|
||||||
import { useArtist } from "@/hooks/use-artist-data"
|
import { useArtist } from "@/hooks/use-artist-data"
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
|
import { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext } from "@/components/ui/carousel"
|
||||||
|
import { useFlash } from "@/hooks/use-flash"
|
||||||
|
// Removed mobile filter scroll area
|
||||||
|
|
||||||
interface ArtistPortfolioProps {
|
interface ArtistPortfolioProps {
|
||||||
artistId: string
|
artistId: string
|
||||||
@ -16,30 +19,110 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
|
|||||||
const [selectedCategory, setSelectedCategory] = useState("All")
|
const [selectedCategory, setSelectedCategory] = useState("All")
|
||||||
const [selectedImage, setSelectedImage] = useState<string | null>(null)
|
const [selectedImage, setSelectedImage] = useState<string | null>(null)
|
||||||
const [scrollY, setScrollY] = useState(0)
|
const [scrollY, setScrollY] = useState(0)
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
// carousel indicator state (mobile)
|
||||||
|
const [carouselApi, setCarouselApi] = useState<CarouselApi | null>(null)
|
||||||
|
const [carouselCount, setCarouselCount] = useState(0)
|
||||||
|
const [carouselCurrent, setCarouselCurrent] = useState(0)
|
||||||
|
const [showSwipeHint, setShowSwipeHint] = useState(true)
|
||||||
|
const [showFullBio, setShowFullBio] = useState(false)
|
||||||
|
const [flashApi, setFlashApi] = useState<CarouselApi | null>(null)
|
||||||
|
|
||||||
// Fetch artist data from API
|
// Fetch artist data from API
|
||||||
const { data: artist, isLoading, error } = useArtist(artistId)
|
const { data: artist, isLoading, error } = useArtist(artistId)
|
||||||
|
const { data: flashItems = [] } = useFlash(artist?.id)
|
||||||
|
|
||||||
// keep a reference to the last focused thumbnail so we can return focus on modal close
|
// keep a reference to the last focused thumbnail so we can return focus on modal close
|
||||||
const lastFocusedRef = useRef<HTMLElement | null>(null)
|
const lastFocusedRef = useRef<HTMLElement | null>(null)
|
||||||
const closeButtonRef = useRef<HTMLButtonElement | null>(null)
|
const closeButtonRef = useRef<HTMLButtonElement | null>(null)
|
||||||
|
const touchStartX = useRef<number | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Enable parallax only on desktop to avoid jank on mobile
|
||||||
|
if (isMobile) return
|
||||||
const handleScroll = () => setScrollY(window.scrollY)
|
const handleScroll = () => setScrollY(window.scrollY)
|
||||||
window.addEventListener("scroll", handleScroll)
|
window.addEventListener("scroll", handleScroll)
|
||||||
return () => window.removeEventListener("scroll", handleScroll)
|
return () => window.removeEventListener("scroll", handleScroll)
|
||||||
|
}, [isMobile])
|
||||||
|
|
||||||
|
// Fade swipe hint after a short delay
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setShowSwipeHint(false), 2500)
|
||||||
|
return () => clearTimeout(t)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Preserve scroll position when modal opens/closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedImage) return
|
||||||
|
const y = window.scrollY
|
||||||
|
const { body } = document
|
||||||
|
body.style.position = "fixed"
|
||||||
|
body.style.top = `-${y}px`
|
||||||
|
body.style.left = "0"
|
||||||
|
body.style.right = "0"
|
||||||
|
return () => {
|
||||||
|
const top = body.style.top
|
||||||
|
body.style.position = ""
|
||||||
|
body.style.top = ""
|
||||||
|
body.style.left = ""
|
||||||
|
body.style.right = ""
|
||||||
|
const restoreY = Math.abs(parseInt(top || "0", 10))
|
||||||
|
window.scrollTo(0, restoreY)
|
||||||
|
}
|
||||||
|
}, [selectedImage])
|
||||||
|
|
||||||
|
// Carousel indicators state wiring
|
||||||
|
useEffect(() => {
|
||||||
|
if (!carouselApi) return
|
||||||
|
setCarouselCount(carouselApi.scrollSnapList().length)
|
||||||
|
setCarouselCurrent(carouselApi.selectedScrollSnap())
|
||||||
|
const onSelect = () => setCarouselCurrent(carouselApi.selectedScrollSnap())
|
||||||
|
carouselApi.on("select", onSelect)
|
||||||
|
return () => {
|
||||||
|
carouselApi.off("select", onSelect)
|
||||||
|
}
|
||||||
|
}, [carouselApi])
|
||||||
|
|
||||||
|
// Flash carousel scale effect based on position (desktop emphasis)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!flashApi) return
|
||||||
|
const updateScales = () => {
|
||||||
|
const root = flashApi.rootNode() as HTMLElement | null
|
||||||
|
const slides = flashApi.slideNodes() as HTMLElement[]
|
||||||
|
if (!root || !slides?.length) return
|
||||||
|
const rect = root.getBoundingClientRect()
|
||||||
|
const centerX = rect.left + rect.width / 2
|
||||||
|
slides.forEach((slide) => {
|
||||||
|
const sRect = slide.getBoundingClientRect()
|
||||||
|
const sCenter = sRect.left + sRect.width / 2
|
||||||
|
const dist = Math.abs(sCenter - centerX)
|
||||||
|
const norm = Math.min(dist / (rect.width / 2), 1) // 0 at center, 1 at edge
|
||||||
|
const scale = 0.92 + (1 - norm) * 0.08 // 0.92 at edge → 1.0 center
|
||||||
|
slide.style.transition = 'transform 200ms ease'
|
||||||
|
slide.style.transform = `scale(${scale})`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
updateScales()
|
||||||
|
flashApi.on('scroll', updateScales)
|
||||||
|
flashApi.on('reInit', updateScales)
|
||||||
|
return () => {
|
||||||
|
flashApi.off('scroll', updateScales)
|
||||||
|
flashApi.off('reInit', updateScales)
|
||||||
|
}
|
||||||
|
}, [flashApi])
|
||||||
|
|
||||||
// Derived lists (safe when `artist` is undefined during initial renders)
|
// Derived lists (safe when `artist` is undefined during initial renders)
|
||||||
const portfolioImages = artist?.portfolioImages || []
|
const portfolioImages = artist?.portfolioImages || []
|
||||||
|
// Exclude profile/non-public images from the displayed gallery
|
||||||
|
const galleryImages = portfolioImages.filter((img) => img.isPublic !== false && !img.tags.includes('profile'))
|
||||||
|
|
||||||
// Get unique categories from tags
|
// Get unique categories from tags (use gallery images only)
|
||||||
const allTags = portfolioImages.flatMap(img => img.tags)
|
const allTags = galleryImages.flatMap(img => img.tags)
|
||||||
const categories = ["All", ...Array.from(new Set(allTags))]
|
const categories = ["All", ...Array.from(new Set(allTags))]
|
||||||
|
|
||||||
const filteredPortfolio = selectedCategory === "All"
|
const filteredPortfolio = selectedCategory === "All"
|
||||||
? portfolioImages
|
? galleryImages
|
||||||
: portfolioImages.filter(img => img.tags.includes(selectedCategory))
|
: galleryImages.filter(img => img.tags.includes(selectedCategory))
|
||||||
|
|
||||||
// keyboard navigation for modal (kept as hooks so they run in same order every render)
|
// keyboard navigation for modal (kept as hooks so they run in same order every render)
|
||||||
const goToIndex = useCallback(
|
const goToIndex = useCallback(
|
||||||
@ -132,25 +215,14 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
|
|||||||
const profileImage = portfolioImages.find(img => img.tags.includes('profile'))?.url ||
|
const profileImage = portfolioImages.find(img => img.tags.includes('profile'))?.url ||
|
||||||
portfolioImages[0]?.url ||
|
portfolioImages[0]?.url ||
|
||||||
"/placeholder.svg"
|
"/placeholder.svg"
|
||||||
|
const bioText = artist.bio || ""
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-black text-white">
|
<div className="min-h-screen bg-black text-white">
|
||||||
{/* Back Button */}
|
{/* Removed Back to Artists button per request */}
|
||||||
<div className="fixed top-6 right-8 z-40">
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
variant="ghost"
|
|
||||||
className="text-white hover:bg-white/20 border border-white/30 backdrop-blur-sm bg-black/40 hover:text-white"
|
|
||||||
>
|
|
||||||
<Link href="/artists">
|
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
|
||||||
Back to Artists
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hero Section with Split Screen */}
|
{/* Hero Section with Split Screen (Desktop only) */}
|
||||||
<section className="relative h-screen overflow-hidden -mt-20">
|
<section className="relative h-screen overflow-hidden -mt-20 hidden md:block">
|
||||||
{/* Left Side - Artist Image */}
|
{/* Left Side - Artist Image */}
|
||||||
<div className="absolute left-0 top-0 w-1/2 h-full" style={{ transform: `translateY(${scrollY * 0.3}px)` }}>
|
<div className="absolute left-0 top-0 w-1/2 h-full" style={{ transform: `translateY(${scrollY * 0.3}px)` }}>
|
||||||
<div className="relative w-full h-full">
|
<div className="relative w-full h-full">
|
||||||
@ -162,14 +234,7 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
|
|||||||
className="object-cover"
|
className="object-cover"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent to-black/50" />
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent to-black/50" />
|
||||||
<div className="absolute top-28 left-8">
|
{/* Availability badge removed */}
|
||||||
<Badge
|
|
||||||
variant={artist.isActive ? "default" : "secondary"}
|
|
||||||
className="bg-white/20 backdrop-blur-sm text-white border-white/30"
|
|
||||||
>
|
|
||||||
{artist.isActive ? "Available" : "Unavailable"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -181,7 +246,6 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
|
|||||||
<div className="px-16 py-20">
|
<div className="px-16 py-20">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="font-playfair text-6xl font-bold mb-4 text-balance leading-tight">{artist.name}</h1>
|
<h1 className="font-playfair text-6xl font-bold mb-4 text-balance leading-tight">{artist.name}</h1>
|
||||||
<p className="text-2xl text-gray-300 mb-6">{artist.specialties.join(", ")}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-gray-300 mb-8 leading-relaxed text-lg max-w-lg">{artist.bio}</p>
|
<p className="text-gray-300 mb-8 leading-relaxed text-lg max-w-lg">{artist.bio}</p>
|
||||||
@ -200,24 +264,9 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{artist.hourlyRate && (
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<DollarSign className="w-5 h-5 text-gray-400" />
|
|
||||||
<span className="text-gray-300">Starting at ${artist.hourlyRate}/hr</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-8">
|
{/* Specialties and pricing hidden on desktop per request */}
|
||||||
<h3 className="font-semibold mb-4 text-lg">Specializes in:</h3>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{artist.specialties.map((style) => (
|
|
||||||
<Badge key={style} variant="outline" className="border-white/30 text-white">
|
|
||||||
{style}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex space-x-4">
|
<div className="flex space-x-4">
|
||||||
<Button asChild size="lg" className="bg-white text-black hover:bg-gray-100 !text-black hover:!text-black">
|
<Button asChild size="lg" className="bg-white text-black hover:bg-gray-100 !text-black hover:!text-black">
|
||||||
@ -242,8 +291,42 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Portfolio Section with Split Screen Layout */}
|
{/* Hero Section - Mobile stacked */}
|
||||||
<section className="relative bg-black">
|
<section className="md:hidden -mt-16">
|
||||||
|
<div className="relative w-full h-[55vh]">
|
||||||
|
<Image
|
||||||
|
src={profileImage}
|
||||||
|
alt={artist.name}
|
||||||
|
fill
|
||||||
|
sizes="100vw"
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-8">
|
||||||
|
<h1 className="font-playfair text-4xl font-bold mb-2 text-balance">{artist.name}</h1>
|
||||||
|
<p className="text-white/80 mb-4 text-base">{artist.specialties.join(", ")}</p>
|
||||||
|
<p className="text-white/80 leading-relaxed mb-2 text-[17px]">
|
||||||
|
{showFullBio ? bioText : bioText.slice(0, 180)}{bioText.length > 180 && !showFullBio ? "…" : ""}
|
||||||
|
</p>
|
||||||
|
{bioText.length > 180 && (
|
||||||
|
<button onClick={() => setShowFullBio((v) => !v)} className="text-white/70 text-sm underline">
|
||||||
|
{showFullBio ? "Show less" : "Read more"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<Button asChild size="lg" className="bg-white text-black hover:bg-gray-100 !text-black hover:!text-black">
|
||||||
|
<Link href={`/book?artist=${artist.slug}`}>Book Appointment</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="lg" className="border-white/30 text-white hover:bg-white hover:text-black bg-transparent">
|
||||||
|
Get Consultation
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Portfolio Section with Split Screen Layout (Desktop only) */}
|
||||||
|
<section className="relative bg-black hidden md:block">
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
{/* Left Side - Portfolio Grid */}
|
{/* Left Side - Portfolio Grid */}
|
||||||
<div className="w-2/3 p-8 overflow-y-auto">
|
<div className="w-2/3 p-8 overflow-y-auto">
|
||||||
@ -358,6 +441,97 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Mobile Portfolio: Carousel + Filters (simplified) */}
|
||||||
|
<section className="md:hidden bg-black">
|
||||||
|
{/* Removed mobile category filters for simplicity */}
|
||||||
|
|
||||||
|
{/* Carousel only */}
|
||||||
|
<div className="px-2 pb-10">
|
||||||
|
{filteredPortfolio.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<p className="text-gray-400">No portfolio images available</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative" aria-label="Portfolio carousel">
|
||||||
|
<Carousel opts={{ align: "start", loop: true }} className="w-full" setApi={setCarouselApi}>
|
||||||
|
<CarouselContent>
|
||||||
|
{filteredPortfolio.map((item) => (
|
||||||
|
<CarouselItem key={item.id} className="basis-full">
|
||||||
|
<div className="w-full h-[70vh] relative">
|
||||||
|
<Image
|
||||||
|
src={item.url || "/placeholder.svg"}
|
||||||
|
alt={item.caption || `${artist.name} portfolio image`}
|
||||||
|
fill
|
||||||
|
sizes="100vw"
|
||||||
|
className="object-contain bg-black"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CarouselItem>)
|
||||||
|
)}
|
||||||
|
</CarouselContent>
|
||||||
|
</Carousel>
|
||||||
|
<div className="pointer-events-none absolute top-2 right-3 rounded-full bg-white/10 backdrop-blur px-2 py-1 text-xs text-white">
|
||||||
|
{filteredPortfolio.length} pieces
|
||||||
|
</div>
|
||||||
|
{/* Swipe hint */}
|
||||||
|
{showSwipeHint && (
|
||||||
|
<div className="pointer-events-none absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-white/10 backdrop-blur px-3 py-1 text-xs text-white">
|
||||||
|
Swipe left or right
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Dots indicators */}
|
||||||
|
<div className="mt-3 flex items-center justify-center gap-2" role="tablist" aria-label="Carousel indicators">
|
||||||
|
{Array.from({ length: carouselCount }).map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => carouselApi?.scrollTo(i)}
|
||||||
|
aria-current={carouselCurrent === i}
|
||||||
|
aria-label={`Go to slide ${i + 1}`}
|
||||||
|
className={`h-2 w-2 rounded-full ${carouselCurrent === i ? "bg-white" : "bg-white/40"}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Available Flash (carousel) */}
|
||||||
|
{flashItems && flashItems.length > 0 && (
|
||||||
|
<section className="bg-black border-t border-white/10 py-10">
|
||||||
|
<div className="px-4 md:px-0 md:max-w-none md:w-screen">
|
||||||
|
<h3 className="font-playfair text-3xl md:text-4xl font-bold mb-6">Available Flash</h3>
|
||||||
|
<div className="relative">
|
||||||
|
<Carousel opts={{ align: "start", loop: true, skipSnaps: false, dragFree: true }} className="w-full relative" setApi={setFlashApi}>
|
||||||
|
<CarouselContent>
|
||||||
|
{flashItems.map((item) => (
|
||||||
|
<CarouselItem key={item.id} className="basis-full md:basis-1/2 lg:basis-1/3">
|
||||||
|
<div className="relative w-full aspect-[4/5] bg-black rounded-md overflow-hidden">
|
||||||
|
<Image src={item.url} alt={item.title || `${artist?.name} flash`} fill sizes="(max-width:768px) 100vw, 33vw" className="object-cover" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end mt-3">
|
||||||
|
<Button asChild size="sm" className="bg-white text-black hover:bg-gray-100 !text-black">
|
||||||
|
<Link href={`/book?artist=${artist?.slug}&flashId=${item.id}`}>Book this</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CarouselItem>
|
||||||
|
))}
|
||||||
|
</CarouselContent>
|
||||||
|
{/* Minimal nav controls */}
|
||||||
|
<CarouselPrevious className="left-4 top-1/2 -translate-y-1/2 size-12 md:size-14 z-10 bg-black/85 text-white shadow-xl border border-white/30 hover:bg-black rounded-full" aria-label="Previous flash" />
|
||||||
|
<CarouselNext className="right-4 top-1/2 -translate-y-1/2 size-12 md:size-14 z-10 bg-black/85 text-white shadow-xl border border-white/30 hover:bg-black rounded-full" aria-label="Next flash" />
|
||||||
|
</Carousel>
|
||||||
|
{/* Edge fade gradients (desktop) */}
|
||||||
|
<div className="pointer-events-none hidden md:block absolute inset-y-0 left-0 w-24 bg-gradient-to-r from-black to-transparent" />
|
||||||
|
<div className="pointer-events-none hidden md:block absolute inset-y-0 right-0 w-24 bg-gradient-to-l from-black to-transparent" />
|
||||||
|
</div>
|
||||||
|
{showSwipeHint && (
|
||||||
|
<div className="pointer-events-none mt-3 text-center text-xs text-white/70">Swipe or use ◀ ▶</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Contact Section */}
|
{/* Contact Section */}
|
||||||
<section className="relative py-32 bg-black border-t border-white/10">
|
<section className="relative py-32 bg-black border-t border-white/10">
|
||||||
<div className="container mx-auto px-8 text-center">
|
<div className="container mx-auto px-8 text-center">
|
||||||
@ -385,22 +559,7 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-16 pt-16 border-t border-white/10">
|
{/* Desktop stats removed per request */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 text-center">
|
|
||||||
<div>
|
|
||||||
<div className="text-3xl font-bold mb-2">{artist.specialties.length}+</div>
|
|
||||||
<div className="text-gray-400">Specialties</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-3xl font-bold mb-2">{portfolioImages.length}</div>
|
|
||||||
<div className="text-gray-400">Portfolio Pieces</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-3xl font-bold mb-2">{artist.hourlyRate ? `$${artist.hourlyRate}` : "Contact"}</div>
|
|
||||||
<div className="text-gray-400">Starting Rate</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -417,6 +576,24 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
|
|||||||
<div
|
<div
|
||||||
className="relative max-w-6xl max-h-[90vh] w-full flex items-center justify-center"
|
className="relative max-w-6xl max-h-[90vh] w-full flex items-center justify-center"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onTouchStart={(e) => {
|
||||||
|
touchStartX.current = e.touches[0].clientX
|
||||||
|
}}
|
||||||
|
onTouchEnd={(e) => {
|
||||||
|
if (touchStartX.current == null) return
|
||||||
|
const dx = e.changedTouches[0].clientX - touchStartX.current
|
||||||
|
const threshold = 40
|
||||||
|
if (Math.abs(dx) > threshold) {
|
||||||
|
if (dx < 0) {
|
||||||
|
const next = (currentIndex + 1) % filteredPortfolio.length
|
||||||
|
goToIndex(next)
|
||||||
|
} else {
|
||||||
|
const prev = (currentIndex - 1 + filteredPortfolio.length) % filteredPortfolio.length
|
||||||
|
goToIndex(prev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
touchStartX.current = null
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Prev */}
|
{/* Prev */}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -2,190 +2,256 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react"
|
import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { motion, AnimatePresence, useMotionValue, useTransform } from "framer-motion"
|
||||||
|
|
||||||
import { useFeatureFlag } from "@/components/feature-flags-provider"
|
import { useFeatureFlag } from "@/components/feature-flags-provider"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { artists } from "@/data/artists"
|
import { artists as staticArtists } from "@/data/artists"
|
||||||
|
import { useActiveArtists } from "@/hooks/use-artists"
|
||||||
|
import type { PublicArtist } from "@/types/database"
|
||||||
|
|
||||||
export function ArtistsSection() {
|
export function ArtistsSection() {
|
||||||
// Minimal animation: fade-in only (no parallax)
|
// Fetch artists from database
|
||||||
const [visibleCards, setVisibleCards] = useState<number[]>([])
|
const { data: dbArtistsData, isLoading, error } = useActiveArtists()
|
||||||
const sectionRef = useRef<HTMLElement>(null)
|
|
||||||
const advancedNavAnimations = useFeatureFlag("ADVANCED_NAV_SCROLL_ANIMATIONS_ENABLED")
|
|
||||||
const allArtistIndices = useMemo(() => Array.from({ length: artists.length }, (_, idx) => idx), [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
// Merge static and database data
|
||||||
if (!advancedNavAnimations) {
|
const artists = useMemo(() => {
|
||||||
setVisibleCards(allArtistIndices)
|
// If still loading or error, use static data
|
||||||
return
|
if (isLoading || error || !dbArtistsData) {
|
||||||
}
|
return staticArtists
|
||||||
setVisibleCards([])
|
}
|
||||||
}, [advancedNavAnimations, allArtistIndices])
|
|
||||||
|
|
||||||
useEffect(() => {
|
// Merge: use database portfolio images, keep static metadata
|
||||||
if (!advancedNavAnimations) return
|
return staticArtists.map(staticArtist => {
|
||||||
const observer = new IntersectionObserver(
|
const dbArtist = dbArtistsData.artists.find(
|
||||||
(entries) => {
|
(db) => db.slug === staticArtist.slug || db.name === staticArtist.name
|
||||||
entries.forEach((entry) => {
|
)
|
||||||
if (entry.isIntersecting) {
|
|
||||||
const cardIndex = Number.parseInt(entry.target.getAttribute("data-index") || "0")
|
// If found in database, use its portfolio images
|
||||||
setVisibleCards((prev) => [...new Set([...prev, cardIndex])])
|
if (dbArtist && dbArtist.portfolioImages.length > 0) {
|
||||||
}
|
return {
|
||||||
|
...staticArtist,
|
||||||
|
workImages: dbArtist.portfolioImages.map(img => img.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to static data
|
||||||
|
return staticArtist
|
||||||
})
|
})
|
||||||
},
|
}, [dbArtistsData, isLoading, error])
|
||||||
{ threshold: 0.2, rootMargin: "0px 0px -10% 0px" },
|
|
||||||
|
// Minimal animation: fade-in only (no parallax)
|
||||||
|
const [visibleCards, setVisibleCards] = useState<number[]>([])
|
||||||
|
const [hoveredCard, setHoveredCard] = useState<number | null>(null)
|
||||||
|
const [portfolioIndices, setPortfolioIndices] = useState<Record<number, number>>({})
|
||||||
|
const sectionRef = useRef<HTMLElement>(null)
|
||||||
|
const advancedNavAnimations = useFeatureFlag("ADVANCED_NAV_SCROLL_ANIMATIONS_ENABLED")
|
||||||
|
const allArtistIndices = useMemo(() => Array.from({ length: artists.length }, (_, idx) => idx), [artists.length])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!advancedNavAnimations) {
|
||||||
|
setVisibleCards(allArtistIndices)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setVisibleCards([])
|
||||||
|
}, [advancedNavAnimations, allArtistIndices])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!advancedNavAnimations) return
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
const cardIndex = Number.parseInt(entry.target.getAttribute("data-index") || "0")
|
||||||
|
setVisibleCards((prev) => [...new Set([...prev, cardIndex])])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ threshold: 0.2, rootMargin: "0px 0px -10% 0px" },
|
||||||
|
)
|
||||||
|
const cards = sectionRef.current?.querySelectorAll("[data-index]")
|
||||||
|
cards?.forEach((card) => observer.observe(card))
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [advancedNavAnimations])
|
||||||
|
|
||||||
|
const cardVisibilityClass = (index: number) => {
|
||||||
|
if (!advancedNavAnimations) return "opacity-100 translate-y-0"
|
||||||
|
return visibleCards.includes(index) ? "opacity-100 translate-y-0" : "opacity-0 translate-y-6"
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardTransitionDelay = (index: number) => {
|
||||||
|
if (!advancedNavAnimations) return undefined
|
||||||
|
return `${index * 40}ms`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vary aspect ratio to create a subtle masonry rhythm
|
||||||
|
const aspectFor = (i: number) => {
|
||||||
|
const variants = ["aspect-[3/4]", "aspect-[4/5]", "aspect-square"]
|
||||||
|
return variants[i % variants.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle hover to cycle through portfolio images
|
||||||
|
const handleHoverStart = (artistIndex: number) => {
|
||||||
|
setHoveredCard(artistIndex)
|
||||||
|
const artist = artists[artistIndex]
|
||||||
|
if (artist.workImages.length > 0) {
|
||||||
|
setPortfolioIndices((prev) => {
|
||||||
|
const currentIndex = prev[artistIndex] ?? 0
|
||||||
|
const nextIndex = (currentIndex + 1) % artist.workImages.length
|
||||||
|
return { ...prev, [artistIndex]: nextIndex }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHoverEnd = () => {
|
||||||
|
setHoveredCard(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPortfolioImage = (artistIndex: number) => {
|
||||||
|
const artist = artists[artistIndex]
|
||||||
|
if (artist.workImages.length === 0) return null
|
||||||
|
const imageIndex = portfolioIndices[artistIndex] ?? 0
|
||||||
|
return artist.workImages[imageIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section ref={sectionRef} id="artists" className="relative overflow-hidden bg-black">
|
||||||
|
{/* Faint logo texture */}
|
||||||
|
<div className="absolute inset-0 opacity-[0.03]">
|
||||||
|
<img
|
||||||
|
src="/united-logo-full.jpg"
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-cover object-center scale-150 blur-[2px]"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="relative z-10 py-14 px-6 lg:px-10">
|
||||||
|
<div className="max-w-[1800px] mx-auto">
|
||||||
|
<div className="grid lg:grid-cols-3 gap-10 items-end mb-10">
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<h2 className="text-6xl lg:text-8xl font-bold tracking-tight mb-4 text-white">ARTISTS</h2>
|
||||||
|
<p className="text-lg lg:text-xl text-gray-200/90 leading-relaxed max-w-2xl">
|
||||||
|
Our exceptional team of tattoo artists, each bringing unique expertise and artistic vision to create your perfect
|
||||||
|
tattoo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
className="bg-white text-black hover:bg-gray-100 px-7 py-3 text-base font-medium tracking-wide shadow-sm rounded-md"
|
||||||
|
>
|
||||||
|
<Link href="/book">BOOK CONSULTATION</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Masonry grid */}
|
||||||
|
<div className="relative z-10 px-6 lg:px-10 pb-24">
|
||||||
|
<div className="max-w-[1800px] mx-auto">
|
||||||
|
{/* columns-based masonry; tighter spacing and wider section */}
|
||||||
|
<div className="columns-1 sm:columns-2 lg:columns-3 gap-4 lg:gap-5 [column-fill:_balance]">
|
||||||
|
{artists.map((artist, i) => {
|
||||||
|
const transitionDelay = cardTransitionDelay(i)
|
||||||
|
const portfolioImage = getPortfolioImage(i)
|
||||||
|
const isHovered = hoveredCard === i
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
key={artist.id}
|
||||||
|
data-index={i}
|
||||||
|
className={`group mb-4 break-inside-avoid transition-all duration-700 ${cardVisibilityClass(i)}`}
|
||||||
|
style={transitionDelay ? { transitionDelay } : undefined}
|
||||||
|
>
|
||||||
|
<Link href={`/artists/${artist.slug}`}>
|
||||||
|
<motion.div
|
||||||
|
className={`relative w-full ${aspectFor(i)} overflow-hidden rounded-md border border-white/10 bg-black cursor-pointer`}
|
||||||
|
onHoverStart={() => handleHoverStart(i)}
|
||||||
|
onHoverEnd={handleHoverEnd}
|
||||||
|
>
|
||||||
|
{/* Base layer: artist portrait */}
|
||||||
|
<div className="absolute inset-0 artist-image">
|
||||||
|
<img
|
||||||
|
src={artist.faceImage || "/placeholder.svg"}
|
||||||
|
alt={`${artist.name} portrait`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Wipe overlay: portfolio image with curved boundary */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isHovered && portfolioImage && (
|
||||||
|
<>
|
||||||
|
{/* SVG clipPath with pronounced wave */}
|
||||||
|
<svg className="absolute w-0 h-0">
|
||||||
|
<defs>
|
||||||
|
<clipPath id={`wipe-curve-${i}`} clipPathUnits="objectBoundingBox">
|
||||||
|
<motion.path
|
||||||
|
initial={{
|
||||||
|
d: "M 0,0 L 1,0 L 1,0 Q 0.75,0 0.5,0 Q 0.25,0 0,0 Z"
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
d: "M 0,0 L 1,0 L 1,1.1 Q 0.75,1.02 0.5,1.1 Q 0.25,1.18 0,1.1 Z"
|
||||||
|
}}
|
||||||
|
exit={{
|
||||||
|
d: "M 0,0 L 1,0 L 1,0 Q 0.75,0 0.5,0 Q 0.25,0 0,0 Z"
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.5, ease: "easeInOut" }}
|
||||||
|
/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Portfolio image with curved clip */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 z-10"
|
||||||
|
style={{
|
||||||
|
clipPath: `url(#wipe-curve-${i})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={portfolioImage}
|
||||||
|
alt={`${artist.name} work`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Minimal footer - only name */}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 z-20 bg-gradient-to-t from-black/80 via-black/20 to-transparent p-4">
|
||||||
|
<h3 className="text-xl font-semibold tracking-tight text-white">{artist.name}</h3>
|
||||||
|
<p className="text-xs font-medium text-white/80">{artist.specialty}</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CTA Footer */}
|
||||||
|
<div className="relative z-20 bg-black text-white py-20 px-6 lg:px-10">
|
||||||
|
<div className="max-w-[1800px] mx-auto text-center">
|
||||||
|
<h3 className="text-5xl lg:text-7xl font-bold tracking-tight mb-8">READY?</h3>
|
||||||
|
<p className="text-xl text-white/70 mb-12 max-w-2xl mx-auto">
|
||||||
|
Choose your artist and start your tattoo journey with United Tattoo.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
className="bg-white text-black hover:bg-gray-100 hover:text-black px-12 py-6 text-xl font-medium tracking-wide shadow-lg border border-white rounded-md"
|
||||||
|
>
|
||||||
|
<Link href="/book">START NOW</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
)
|
)
|
||||||
const cards = sectionRef.current?.querySelectorAll("[data-index]")
|
|
||||||
cards?.forEach((card) => observer.observe(card))
|
|
||||||
return () => observer.disconnect()
|
|
||||||
}, [advancedNavAnimations])
|
|
||||||
|
|
||||||
const cardVisibilityClass = (index: number) => {
|
|
||||||
if (!advancedNavAnimations) return "opacity-100 translate-y-0"
|
|
||||||
return visibleCards.includes(index) ? "opacity-100 translate-y-0" : "opacity-0 translate-y-6"
|
|
||||||
}
|
|
||||||
|
|
||||||
const cardTransitionDelay = (index: number) => {
|
|
||||||
if (!advancedNavAnimations) return undefined
|
|
||||||
return `${index * 40}ms`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vary aspect ratio to create a subtle masonry rhythm
|
|
||||||
const aspectFor = (i: number) => {
|
|
||||||
const variants = ["aspect-[3/4]", "aspect-[4/5]", "aspect-square"]
|
|
||||||
return variants[i % variants.length]
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section ref={sectionRef} id="artists" className="relative overflow-hidden bg-black">
|
|
||||||
{/* Faint logo texture */}
|
|
||||||
<div className="absolute inset-0 opacity-[0.03]">
|
|
||||||
<img
|
|
||||||
src="/united-logo-full.jpg"
|
|
||||||
alt=""
|
|
||||||
className="w-full h-full object-cover object-center scale-150 blur-[2px]"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="relative z-10 py-14 px-6 lg:px-10">
|
|
||||||
<div className="max-w-[1800px] mx-auto">
|
|
||||||
<div className="grid lg:grid-cols-3 gap-10 items-end mb-10">
|
|
||||||
<div className="lg:col-span-2">
|
|
||||||
<h2 className="text-6xl lg:text-8xl font-bold tracking-tight mb-4 text-white">ARTISTS</h2>
|
|
||||||
<p className="text-lg lg:text-xl text-gray-200/90 leading-relaxed max-w-2xl">
|
|
||||||
Our exceptional team of tattoo artists, each bringing unique expertise and artistic vision to create your perfect
|
|
||||||
tattoo.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
className="bg-white text-black hover:bg-gray-100 px-7 py-3 text-base font-medium tracking-wide shadow-sm rounded-md"
|
|
||||||
>
|
|
||||||
<Link href="/book">BOOK CONSULTATION</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Masonry grid */}
|
|
||||||
<div className="relative z-10 px-6 lg:px-10 pb-24">
|
|
||||||
<div className="max-w-[1800px] mx-auto">
|
|
||||||
{/* columns-based masonry; tighter spacing and wider section */}
|
|
||||||
<div className="columns-1 sm:columns-2 lg:columns-3 gap-4 lg:gap-5 [column-fill:_balance]">
|
|
||||||
{artists.map((artist, i) => {
|
|
||||||
const transitionDelay = cardTransitionDelay(i)
|
|
||||||
return (
|
|
||||||
<article
|
|
||||||
key={artist.id}
|
|
||||||
data-index={i}
|
|
||||||
className={`group mb-4 break-inside-avoid transition-all duration-700 ${cardVisibilityClass(i)}`}
|
|
||||||
style={transitionDelay ? { transitionDelay } : undefined}
|
|
||||||
>
|
|
||||||
<div className={`relative w-full ${aspectFor(i)} overflow-hidden rounded-md border border-white/10 bg-black`}>
|
|
||||||
{/* Imagery */}
|
|
||||||
<div className="absolute inset-0 artist-image">
|
|
||||||
<img
|
|
||||||
src={artist.workImages?.[0] || "/placeholder.svg"}
|
|
||||||
alt={`${artist.name} tattoo work`}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-black/30"></div>
|
|
||||||
|
|
||||||
{/* Portrait with feathered mask */}
|
|
||||||
<div className="absolute left-0 top-0 w-3/5 h-full pointer-events-none">
|
|
||||||
<img
|
|
||||||
src={artist.faceImage || "/placeholder.svg"}
|
|
||||||
alt={`${artist.name} portrait`}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
style={{
|
|
||||||
maskImage: "linear-gradient(to right, black 0%, black 70%, transparent 100%)",
|
|
||||||
WebkitMaskImage: "linear-gradient(to right, black 0%, black 70%, transparent 100%)",
|
|
||||||
}}
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Softer hover wash (replaces heavy overlay) */}
|
|
||||||
<div className="absolute inset-0 z-10 transition-colors duration-300 group-hover:bg-black/10" />
|
|
||||||
|
|
||||||
{/* Top-left experience pill */}
|
|
||||||
<div className="absolute top-3 left-3 z-20">
|
|
||||||
<span className="text-[10px] font-medium tracking-widest text-white uppercase bg-black/70 backdrop-blur-sm px-2.5 py-0.5 rounded-full">
|
|
||||||
{artist.experience}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Minimal footer */}
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 z-20 bg-gradient-to-t from-black/80 via-black/20 to-transparent p-4">
|
|
||||||
<h3 className="text-xl font-semibold tracking-tight text-white">{artist.name}</h3>
|
|
||||||
<p className="text-xs font-medium text-white/80 mb-3">{artist.specialty}</p>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
size="sm"
|
|
||||||
className="h-8 rounded-md px-3 bg-white/90 text-black hover:bg-white text-xs font-medium tracking-wide"
|
|
||||||
>
|
|
||||||
<Link href={`/artists/${artist.slug}`}>PORTFOLIO</Link>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
size="sm"
|
|
||||||
className="h-8 rounded-md px-3 bg-transparent text-white border border-white/25 hover:bg-white/10 text-xs font-medium tracking-wide"
|
|
||||||
>
|
|
||||||
<Link href={`/book?artist=${artist.slug}`}>BOOK</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CTA Footer */}
|
|
||||||
<div className="relative z-20 bg-black text-white py-20 px-6 lg:px-10">
|
|
||||||
<div className="max-w-[1800px] mx-auto text-center">
|
|
||||||
<h3 className="text-5xl lg:text-7xl font-bold tracking-tight mb-8">READY?</h3>
|
|
||||||
<p className="text-xl text-white/70 mb-12 max-w-2xl mx-auto">
|
|
||||||
Choose your artist and start your tattoo journey with United Tattoo.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
className="bg-white text-black hover:bg-gray-100 hover:text-black px-12 py-6 text-xl font-medium tracking-wide shadow-lg border border-white rounded-md"
|
|
||||||
>
|
|
||||||
<Link href="/book">START NOW</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
|
import { useSearchParams } from "next/navigation"
|
||||||
|
import { useEffect } from "react"
|
||||||
|
import { fetchFlashItem } from "@/hooks/use-flash"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState, useMemo } from "react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
@ -13,7 +16,8 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { useFeatureFlag } from "@/components/feature-flags-provider"
|
import { useFeatureFlag } from "@/components/feature-flags-provider"
|
||||||
import { useArtists } from "@/hooks/use-artist-data"
|
import { useArtists } from "@/hooks/use-artist-data"
|
||||||
import { CalendarIcon, DollarSign, MessageSquare, User, Loader2 } from "lucide-react"
|
import { useAvailability } from "@/hooks/use-availability"
|
||||||
|
import { CalendarIcon, DollarSign, MessageSquare, User, Loader2, CheckCircle2, XCircle, AlertCircle } from "lucide-react"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
|
||||||
@ -32,6 +36,8 @@ interface BookingFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function BookingForm({ artistId }: BookingFormProps) {
|
export function BookingForm({ artistId }: BookingFormProps) {
|
||||||
|
const search = useSearchParams()
|
||||||
|
const flashIdParam = search?.get('flashId') || undefined
|
||||||
const [step, setStep] = useState(1)
|
const [step, setStep] = useState(1)
|
||||||
const [selectedDate, setSelectedDate] = useState<Date>()
|
const [selectedDate, setSelectedDate] = useState<Date>()
|
||||||
|
|
||||||
@ -67,11 +73,63 @@ export function BookingForm({ artistId }: BookingFormProps) {
|
|||||||
depositAmount: 100,
|
depositAmount: 100,
|
||||||
agreeToTerms: false,
|
agreeToTerms: false,
|
||||||
agreeToDeposit: false,
|
agreeToDeposit: false,
|
||||||
|
flashId: flashIdParam || "",
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedArtist = artists?.find((a) => a.slug === formData.artistId)
|
const selectedArtist = artists?.find((a) => a.slug === formData.artistId)
|
||||||
const selectedSize = tattooSizes.find((size) => size.size === formData.tattooSize)
|
const selectedSize = tattooSizes.find((size) => size.size === formData.tattooSize)
|
||||||
const bookingEnabled = useFeatureFlag("BOOKING_ENABLED")
|
const bookingEnabled = useFeatureFlag("BOOKING_ENABLED")
|
||||||
|
// Prefill from flash piece if provided
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
if (!flashIdParam) return
|
||||||
|
const item = await fetchFlashItem(flashIdParam)
|
||||||
|
if (!item) return
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
tattooDescription: [item.title, item.description].filter(Boolean).join(' - '),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}, [flashIdParam])
|
||||||
|
|
||||||
|
// Calculate appointment start and end times for availability checking
|
||||||
|
const { appointmentStart, appointmentEnd } = useMemo(() => {
|
||||||
|
if (!selectedDate || !formData.preferredTime || !selectedSize) {
|
||||||
|
return { appointmentStart: null, appointmentEnd: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse time slot (e.g., "2:00 PM")
|
||||||
|
const timeParts = formData.preferredTime.match(/(\d+):(\d+)\s*(AM|PM)/i)
|
||||||
|
if (!timeParts) return { appointmentStart: null, appointmentEnd: null }
|
||||||
|
|
||||||
|
let hours = parseInt(timeParts[1])
|
||||||
|
const minutes = parseInt(timeParts[2])
|
||||||
|
const meridiem = timeParts[3].toUpperCase()
|
||||||
|
|
||||||
|
if (meridiem === 'PM' && hours !== 12) hours += 12
|
||||||
|
if (meridiem === 'AM' && hours === 12) hours = 0
|
||||||
|
|
||||||
|
const start = new Date(selectedDate)
|
||||||
|
start.setHours(hours, minutes, 0, 0)
|
||||||
|
|
||||||
|
// Estimate duration from tattoo size (use max hours)
|
||||||
|
const durationHours = parseInt(selectedSize.duration.split('-')[1] || selectedSize.duration.split('-')[0])
|
||||||
|
const end = new Date(start.getTime() + durationHours * 60 * 60 * 1000)
|
||||||
|
|
||||||
|
return {
|
||||||
|
appointmentStart: start.toISOString(),
|
||||||
|
appointmentEnd: end.toISOString(),
|
||||||
|
}
|
||||||
|
}, [selectedDate, formData.preferredTime, selectedSize])
|
||||||
|
|
||||||
|
// Check availability in real-time
|
||||||
|
const availability = useAvailability({
|
||||||
|
artistId: selectedArtist?.id || null,
|
||||||
|
startTime: appointmentStart,
|
||||||
|
endTime: appointmentEnd,
|
||||||
|
enabled: !!selectedArtist && !!appointmentStart && !!appointmentEnd && step === 2,
|
||||||
|
})
|
||||||
|
|
||||||
const handleInputChange = (field: string, value: any) => {
|
const handleInputChange = (field: string, value: any) => {
|
||||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||||
@ -337,6 +395,46 @@ export function BookingForm({ artistId }: BookingFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Availability Indicator */}
|
||||||
|
{selectedArtist && selectedDate && formData.preferredTime && selectedSize && (
|
||||||
|
<div className={`p-4 rounded-lg border-2 ${
|
||||||
|
availability.checking
|
||||||
|
? 'bg-gray-50 border-gray-300'
|
||||||
|
: availability.available
|
||||||
|
? 'bg-green-50 border-green-300'
|
||||||
|
: 'bg-red-50 border-red-300'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{availability.checking ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin text-gray-600" />
|
||||||
|
<span className="font-medium text-gray-700">Checking availability...</span>
|
||||||
|
</>
|
||||||
|
) : availability.available ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-green-600" />
|
||||||
|
<span className="font-medium text-green-700">Time slot available!</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<XCircle className="w-5 h-5 text-red-600" />
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-red-700 block">Time slot not available</span>
|
||||||
|
{availability.reason && (
|
||||||
|
<span className="text-sm text-red-600">{availability.reason}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!availability.available && !availability.checking && (
|
||||||
|
<p className="mt-2 text-sm text-red-600">
|
||||||
|
Please select a different date or time, or provide an alternative below.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="p-4 bg-blue-50 rounded-lg">
|
<div className="p-4 bg-blue-50 rounded-lg">
|
||||||
<h4 className="font-medium mb-2 text-blue-900">Alternative Date & Time</h4>
|
<h4 className="font-medium mb-2 text-blue-900">Alternative Date & Time</h4>
|
||||||
<p className="text-sm text-blue-700 mb-4">
|
<p className="text-sm text-blue-700 mb-4">
|
||||||
@ -598,8 +696,15 @@ export function BookingForm({ artistId }: BookingFormProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{step < 4 ? (
|
{step < 4 ? (
|
||||||
<Button type="button" onClick={nextStep}>
|
<Button
|
||||||
Next Step
|
type="button"
|
||||||
|
onClick={nextStep}
|
||||||
|
disabled={
|
||||||
|
// Disable if on step 2 and slot is not available or still checking
|
||||||
|
step === 2 && (availability.checking || !availability.available)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{step === 2 && availability.checking ? 'Checking...' : 'Next Step'}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
81
components/construction-banner.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
|
||||||
|
export default function ConstructionBanner() {
|
||||||
|
const [isVisible, setIsVisible] = useState(false)
|
||||||
|
const bannerRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
// Initialize from sessionStorage
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const dismissed = sessionStorage.getItem("constructionBannerDismissed") === "1"
|
||||||
|
setIsVisible(!dismissed)
|
||||||
|
} catch {
|
||||||
|
// If sessionStorage is unavailable, default to showing the banner
|
||||||
|
setIsVisible(true)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Manage root class and CSS var for offset while visible
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement
|
||||||
|
|
||||||
|
if (!isVisible) {
|
||||||
|
root.classList.remove("has-site-banner")
|
||||||
|
root.style.removeProperty("--site-banner-height")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
root.classList.add("has-site-banner")
|
||||||
|
|
||||||
|
const updateBannerHeight = () => {
|
||||||
|
const height = bannerRef.current?.offsetHeight ?? 0
|
||||||
|
root.style.setProperty("--site-banner-height", `${height}px`)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBannerHeight()
|
||||||
|
window.addEventListener("resize", updateBannerHeight)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", updateBannerHeight)
|
||||||
|
}
|
||||||
|
}, [isVisible])
|
||||||
|
|
||||||
|
if (!isVisible) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={bannerRef}
|
||||||
|
className="fixed top-0 left-0 right-0 z-[60] bg-amber-500 text-black border-b border-amber-600"
|
||||||
|
role="region"
|
||||||
|
aria-label="Site announcement"
|
||||||
|
>
|
||||||
|
<div className="relative max-w-[1800px] mx-auto px-4 lg:px-6 py-2 text-center text-sm">
|
||||||
|
<span className="font-semibold">🚧 Site Under Construction.</span>{" "}
|
||||||
|
For bookings, call {" "}
|
||||||
|
<a href="tel:17196989004" className="font-semibold underline">
|
||||||
|
719-698-9004
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem("constructionBannerDismissed", "1")
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
setIsVisible(false)
|
||||||
|
}}
|
||||||
|
aria-label="Dismiss announcement"
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-black/80 hover:text-black hover:bg-amber-400/70 transition-colors"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -72,7 +72,6 @@ export function Footer() {
|
|||||||
<ul className="space-y-3 text-base">
|
<ul className="space-y-3 text-base">
|
||||||
{[
|
{[
|
||||||
{ name: "CHRISTY_LUMBERG", count: "" },
|
{ name: "CHRISTY_LUMBERG", count: "" },
|
||||||
{ name: "ANGEL_ANDRADE", count: "" },
|
|
||||||
{ name: "STEVEN_SOLE", count: "" },
|
{ name: "STEVEN_SOLE", count: "" },
|
||||||
{ name: "DONOVAN_L", count: "" },
|
{ name: "DONOVAN_L", count: "" },
|
||||||
{ name: "VIEW_ALL", count: "" },
|
{ name: "VIEW_ALL", count: "" },
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
# Copy artist portraits
|
# Copy artist portraits
|
||||||
cp "united-tattoo/temp/img/christylumbergportrait1.avif" "united-tattoo/public/artists/christy-lumberg-portrait.jpg" 2>/dev/null
|
cp "united-tattoo/temp/img/christylumbergportrait1.avif" "united-tattoo/public/artists/christy-lumberg-portrait.jpg" 2>/dev/null
|
||||||
cp "united-tattoo/temp/artist-pages/Angel Andrade/2 - Pictures & Bio/IMG_4856-.jpg" "united-tattoo/public/artists/angel-andrade-portrait.jpg" 2>/dev/null
|
|
||||||
cp "united-tattoo/temp/artist-pages/Donovan Lankford/2 - Pictures & Bio/DL (SQUARE).jpg" "united-tattoo/public/artists/donovan-lankford-portrait.jpg" 2>/dev/null
|
cp "united-tattoo/temp/artist-pages/Donovan Lankford/2 - Pictures & Bio/DL (SQUARE).jpg" "united-tattoo/public/artists/donovan-lankford-portrait.jpg" 2>/dev/null
|
||||||
cp "united-tattoo/temp/artist-pages/Heather Santistevan/2 - Pictures & Bio/Photoleap_12_12_2024_10_33_15_WCJy6.jpg" "united-tattoo/public/artists/heather-santistevan-portrait.jpg" 2>/dev/null
|
cp "united-tattoo/temp/artist-pages/Heather Santistevan/2 - Pictures & Bio/Photoleap_12_12_2024_10_33_15_WCJy6.jpg" "united-tattoo/public/artists/heather-santistevan-portrait.jpg" 2>/dev/null
|
||||||
cp "united-tattoo/temp/artist-pages/John Lapides/2 - Pictures & Bio/IMG_9058.jpg" "united-tattoo/public/artists/john-lapides-portrait.jpg" 2>/dev/null
|
cp "united-tattoo/temp/artist-pages/John Lapides/2 - Pictures & Bio/IMG_9058.jpg" "united-tattoo/public/artists/john-lapides-portrait.jpg" 2>/dev/null
|
||||||
@ -13,12 +12,6 @@ cp "united-tattoo/public/placeholder-user.jpg" "united-tattoo/public/artists/ama
|
|||||||
cp "united-tattoo/public/placeholder-user.jpg" "united-tattoo/public/artists/ej-segoviano-portrait.jpg" 2>/dev/null
|
cp "united-tattoo/public/placeholder-user.jpg" "united-tattoo/public/artists/ej-segoviano-portrait.jpg" 2>/dev/null
|
||||||
cp "united-tattoo/public/placeholder-user.jpg" "united-tattoo/public/artists/pako-martinez-portrait.jpg" 2>/dev/null
|
cp "united-tattoo/public/placeholder-user.jpg" "united-tattoo/public/artists/pako-martinez-portrait.jpg" 2>/dev/null
|
||||||
|
|
||||||
# Copy some tattoo work samples from Angel Andrade
|
|
||||||
cp "united-tattoo/temp/artist-pages/Angel Andrade/Screenshot_20241219_155220_Instagram.jpg" "united-tattoo/public/artists/angel-andrade-work-1.jpg" 2>/dev/null
|
|
||||||
cp "united-tattoo/temp/artist-pages/Angel Andrade/Screenshot_20241219_155515_Instagram.jpg" "united-tattoo/public/artists/angel-andrade-work-2.jpg" 2>/dev/null
|
|
||||||
cp "united-tattoo/temp/artist-pages/Angel Andrade/Screenshot_20241219_155729_Instagram.jpg" "united-tattoo/public/artists/angel-andrade-work-3.jpg" 2>/dev/null
|
|
||||||
cp "united-tattoo/temp/artist-pages/Angel Andrade/Screenshot_20241219_155746_Instagram.jpg" "united-tattoo/public/artists/angel-andrade-work-4.jpg" 2>/dev/null
|
|
||||||
|
|
||||||
# Copy Donovan's work
|
# Copy Donovan's work
|
||||||
cp "united-tattoo/temp/artist-pages/Donovan Lankford/3 - Tattoo Portfolio/Screenshot_20241217_150344_Instagram.jpg" "united-tattoo/public/artists/donovan-lankford-work-1.jpg" 2>/dev/null
|
cp "united-tattoo/temp/artist-pages/Donovan Lankford/3 - Tattoo Portfolio/Screenshot_20241217_150344_Instagram.jpg" "united-tattoo/public/artists/donovan-lankford-work-1.jpg" 2>/dev/null
|
||||||
cp "united-tattoo/temp/artist-pages/Donovan Lankford/3 - Tattoo Portfolio/Screenshot_20241217_150550_Instagram.jpg" "united-tattoo/public/artists/donovan-lankford-work-2.jpg" 2>/dev/null
|
cp "united-tattoo/temp/artist-pages/Donovan Lankford/3 - Tattoo Portfolio/Screenshot_20241217_150550_Instagram.jpg" "united-tattoo/public/artists/donovan-lankford-work-2.jpg" 2>/dev/null
|
||||||
|
|||||||
588
data/artists.ts
@ -1,301 +1,317 @@
|
|||||||
export interface Artist {
|
export interface Artist {
|
||||||
id: number
|
id: number
|
||||||
slug: string
|
slug: string
|
||||||
name: string
|
name: string
|
||||||
title: string
|
title: string
|
||||||
specialty: string
|
specialty: string
|
||||||
faceImage: string
|
faceImage: string
|
||||||
workImages: string[]
|
workImages: string[]
|
||||||
bio: string
|
bio: string
|
||||||
experience: string
|
experience: string
|
||||||
rating: number
|
rating: number
|
||||||
reviews: number
|
reviews: number
|
||||||
availability: string
|
availability: string
|
||||||
styles: string[]
|
styles: string[]
|
||||||
description1: {
|
description1: {
|
||||||
text: string
|
text: string
|
||||||
details: string[]
|
details: string[]
|
||||||
}
|
}
|
||||||
description2?: {
|
description2?: {
|
||||||
text: string
|
text: string
|
||||||
details: string[]
|
details: string[]
|
||||||
}
|
}
|
||||||
description3?: {
|
description3?: {
|
||||||
text: string
|
text: string
|
||||||
details: string[]
|
details: string[]
|
||||||
}
|
}
|
||||||
instagram?: string
|
instagram?: string
|
||||||
facebook?: string
|
facebook?: string
|
||||||
twitter?: string
|
twitter?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const artists: Artist[] = [
|
export const artists: Artist[] = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
slug: "christy-lumberg",
|
slug: "christy-lumberg",
|
||||||
name: "Christy Lumberg",
|
name: "Christy Lumberg",
|
||||||
title: "The Ink Mama",
|
title: "The Ink Mama",
|
||||||
specialty: "Expert Cover-Up & Illustrative Specialist",
|
specialty: "Expert Cover-Up & Illustrative Specialist",
|
||||||
faceImage: "/artists/christy-lumberg-portrait.jpg",
|
faceImage: "/artists/christy-lumberg-portrait.jpg",
|
||||||
workImages: [
|
workImages: [
|
||||||
"/artists/christy-lumberg-work-1.jpg",
|
"/artists/christy-lumberg-work-1.jpg",
|
||||||
"/artists/christy-lumberg-work-2.jpg",
|
"/artists/christy-lumberg-work-2.jpg",
|
||||||
"/artists/christy-lumberg-work-3.jpg",
|
"/artists/christy-lumberg-work-3.jpg",
|
||||||
"/artists/christy-lumberg-work-4.jpg"
|
"/artists/christy-lumberg-work-4.jpg"
|
||||||
],
|
],
|
||||||
bio: "With over 22 years of experience, Christy Lumberg is a powerhouse in the tattoo industry, known for her exceptional cover-ups, tattoo makeovers, and bold illustrative designs.",
|
bio: "With over 22 years of experience, Christy Lumberg is a powerhouse in the tattoo industry, known for her exceptional cover-ups, tattoo makeovers, and bold illustrative designs.",
|
||||||
experience: "22+ years",
|
experience: "22+ years",
|
||||||
rating: 5.0,
|
rating: 5.0,
|
||||||
reviews: 245,
|
reviews: 245,
|
||||||
availability: "Available",
|
availability: "Available",
|
||||||
styles: ["Cover-ups", "Illustrative", "Black & Grey", "Color Work", "Tattoo Makeovers"],
|
styles: ["Cover-ups", "Illustrative", "Black & Grey", "Color Work", "Tattoo Makeovers"],
|
||||||
description1: {
|
description1: {
|
||||||
text: "Meet Christy Lumberg - The Ink Mama of United Tattoo",
|
text: "Meet Christy Lumberg - The Ink Mama of United Tattoo",
|
||||||
details: [
|
details: [
|
||||||
"With over 22 years of experience, Christy Lumberg is a powerhouse in the tattoo industry, known for her exceptional cover-ups, tattoo makeovers, and bold illustrative designs.",
|
"With over 22 years of experience, Christy Lumberg is a powerhouse in the tattoo industry, known for her exceptional cover-ups, tattoo makeovers, and bold illustrative designs.",
|
||||||
"Whether you're looking to transform old ink, refresh a faded piece, or bring a brand-new vision to life, Christy's precision and artistry deliver next-level results."
|
"Whether you're looking to transform old ink, refresh a faded piece, or bring a brand-new vision to life, Christy's precision and artistry deliver next-level results."
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
description2: {
|
||||||
|
text: "CEO & Trusted Artist",
|
||||||
|
details: [
|
||||||
|
"As the CEO of United Tattoo, based in Fountain and Colorado Springs, she has cultivated a space where artistry, creativity, and expertise thrive.",
|
||||||
|
"Clients travel from all over to sit in her chair—because when it comes to experience, Christy is the name you trust."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
description3: {
|
||||||
|
text: "Specialties & Portfolio",
|
||||||
|
details: [
|
||||||
|
"✔ Cover-Up Specialist – Turning past ink into stunning new pieces.",
|
||||||
|
"✔ Tattoo Makeovers – Revitalizing and enhancing faded tattoos.",
|
||||||
|
"✔ Illustrative Style – From bold black-and-grey to vibrant, intricate designs.",
|
||||||
|
"✔ Trusted Artist in Fountain & Colorado Springs – A leader in the local tattoo scene.",
|
||||||
|
"Before & After cover-ups and transformations.",
|
||||||
|
"Illustrative masterpieces in full color and black and grey."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
instagram: "https://www.instagram.com/inkmama719",
|
||||||
|
facebook: "",
|
||||||
|
twitter: ""
|
||||||
},
|
},
|
||||||
description2: {
|
{
|
||||||
text: "CEO & Trusted Artist",
|
id: 3,
|
||||||
details: [
|
slug: "amari-rodriguez",
|
||||||
"As the CEO of United Tattoo, based in Fountain and Colorado Springs, she has cultivated a space where artistry, creativity, and expertise thrive.",
|
name: "Amari Kyss",
|
||||||
"Clients travel from all over to sit in her chair—because when it comes to experience, Christy is the name you trust."
|
title: "",
|
||||||
]
|
specialty: "American & Japanese Traditional",
|
||||||
|
faceImage: "/artists/amari-rodriguez-portrait.jpg",
|
||||||
|
workImages: [
|
||||||
|
"/artists/amari-rodriguez-work-1.jpg",
|
||||||
|
"/artists/amari-rodriguez-work-2.jpg",
|
||||||
|
"/artists/amari-rodriguez-work-3.jpg"
|
||||||
|
],
|
||||||
|
bio: "Colorado Springs Tattoo artist focused on creating meaningful, timeless work that blends bold color traditional with black and grey stipple styles.",
|
||||||
|
experience: "",
|
||||||
|
rating: 5.0,
|
||||||
|
reviews: 12,
|
||||||
|
availability: "Available",
|
||||||
|
styles: ["American/Japanese Traditional", "Neo-Traditional", "Black & Grey", "Fine Line", "Lettering"],
|
||||||
|
description1: {
|
||||||
|
text: "Rising Talent",
|
||||||
|
details: [
|
||||||
|
"Amari Tattoos with love and intention. She puts her heart into every piece she creates."
|
||||||
|
]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
description3: {
|
{
|
||||||
text: "Specialties & Portfolio",
|
id: 4,
|
||||||
details: [
|
slug: "donovan-lankford",
|
||||||
"✔ Cover-Up Specialist – Turning past ink into stunning new pieces.",
|
name: "Donovan Lankford",
|
||||||
"✔ Tattoo Makeovers – Revitalizing and enhancing faded tattoos.",
|
title: "",
|
||||||
"✔ Illustrative Style – From bold black-and-grey to vibrant, intricate designs.",
|
specialty: "Boldly Illustrated",
|
||||||
"✔ Trusted Artist in Fountain & Colorado Springs – A leader in the local tattoo scene.",
|
faceImage: "/artists/donovan-lankford-portrait.jpg",
|
||||||
"Before & After cover-ups and transformations.",
|
workImages: [
|
||||||
"Illustrative masterpieces in full color and black and grey."
|
"/artists/donovan-lankford-work-1.jpg",
|
||||||
]
|
"/artists/donovan-lankford-work-2.jpg",
|
||||||
|
"/artists/donovan-lankford-work-3.jpg",
|
||||||
|
"/artists/donovan-lankford-work-4.jpg"
|
||||||
|
],
|
||||||
|
bio: "Donovan's artistry seamlessly merges bold and intricate illustrative details, infusing each tattoo with unparalleled passion and creativity.",
|
||||||
|
experience: "8 years",
|
||||||
|
rating: 4.9,
|
||||||
|
reviews: 167,
|
||||||
|
availability: "Available",
|
||||||
|
styles: ["Anime", "Illustrative", "Black & Grey", "Dotwork", "Neo-Traditional"],
|
||||||
|
description1: {
|
||||||
|
text: "Boldly Illustrated",
|
||||||
|
details: [
|
||||||
|
"Donovan's artistry seamlessly merges bold and intricate illustrative details, infusing each tattoo with unparalleled passion and creativity.",
|
||||||
|
"From anime-inspired designs to striking black and grey illustrative work and meticulous dotwork, his versatility brings every vision to life."
|
||||||
|
]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
instagram: "https://www.instagram.com/inkmama719",
|
{
|
||||||
facebook: "",
|
id: 5,
|
||||||
twitter: ""
|
slug: "efrain-ej-segoviano",
|
||||||
},
|
name: "Efrain 'EJ' Segoviano",
|
||||||
{
|
title: "",
|
||||||
id: 2,
|
specialty: "Evolving Boldly",
|
||||||
slug: "angel-andrade",
|
faceImage: "/artists/ej-segoviano-portrait.jpg",
|
||||||
name: "Angel Andrade",
|
workImages: [
|
||||||
title: "",
|
"/artists/ej-segoviano-work-1.jpg",
|
||||||
specialty: "Precision in the details",
|
"/artists/ej-segoviano-work-2.jpg",
|
||||||
faceImage: "/artists/angel-andrade-portrait.jpg",
|
"/artists/ej-segoviano-work-3.jpg"
|
||||||
workImages: [
|
],
|
||||||
"/artists/angel-andrade-work-1.jpg",
|
bio: "EJ is a self-taught tattoo artist redefining creativity with fresh perspectives and undeniable skill.",
|
||||||
"/artists/angel-andrade-work-2.jpg",
|
experience: "6 years",
|
||||||
"/artists/angel-andrade-work-3.jpg",
|
rating: 4.7,
|
||||||
"/artists/angel-andrade-work-4.jpg"
|
reviews: 93,
|
||||||
],
|
availability: "Available",
|
||||||
bio: "From lifelike micro designs to clean, modern aesthetics, Angel's tattoos are proof that big impact comes in small packages.",
|
styles: ["Black & Grey", "High Contrast", "Realism", "Illustrative"],
|
||||||
experience: "5 years",
|
description1: {
|
||||||
rating: 4.8,
|
text: "Evolving Boldly",
|
||||||
reviews: 89,
|
details: [
|
||||||
availability: "Available",
|
"EJ is a self-taught tattoo artist redefining creativity with fresh perspectives and undeniable skill.",
|
||||||
styles: ["Fine Line", "Micro Realism", "Black & Grey", "Minimalist", "Geometric"],
|
"A rising star in the industry, his high-contrast black and grey designs showcase a bold, evolving artistry that leaves a lasting impression."
|
||||||
description1: {
|
]
|
||||||
text: "Precision in the details",
|
}
|
||||||
details: [
|
},
|
||||||
"From lifelike micro designs to clean, modern aesthetics, Angel's tattoos are proof that big impact comes in small packages.",
|
{
|
||||||
"Angel specializes in fine line work and micro realism, creating intricate designs that showcase exceptional attention to detail."
|
id: 6,
|
||||||
]
|
slug: "heather-santistevan",
|
||||||
|
name: "Heather Santistevan",
|
||||||
|
title: "",
|
||||||
|
specialty: "Art in Motion",
|
||||||
|
faceImage: "",
|
||||||
|
workImages: [
|
||||||
|
"/artists/heather-santistevan-work-1.jpg",
|
||||||
|
"/artists/heather-santistevan-work-2.jpg",
|
||||||
|
"/artists/heather-santistevan-work-3.jpg",
|
||||||
|
"/artists/heather-santistevan-work-4.jpg"
|
||||||
|
],
|
||||||
|
bio: "With a creative journey spanning since 2012, Heather brings unmatched artistry to the tattoo world.",
|
||||||
|
experience: "12+ years",
|
||||||
|
rating: 4.8,
|
||||||
|
reviews: 178,
|
||||||
|
availability: "Limited slots",
|
||||||
|
styles: ["Watercolor", "Embroidery Style", "Patchwork", "Illustrative", "Color Work"],
|
||||||
|
description1: {
|
||||||
|
text: "Art in Motion",
|
||||||
|
details: [
|
||||||
|
"With a creative journey spanning since 2012, Heather Santistevan brings unmatched artistry to the tattoo world.",
|
||||||
|
"Specializing in vibrant watercolor designs and intricate embroidery-style patchwork, her work turns skin into stunning, wearable art."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
slug: "john-lapides",
|
||||||
|
name: "John Lapides",
|
||||||
|
title: "",
|
||||||
|
specialty: "Sharp and Crisp",
|
||||||
|
faceImage: "/artists/john-lapides-portrait.jpg",
|
||||||
|
workImages: [
|
||||||
|
"/artists/john-lapides-work-1.jpg",
|
||||||
|
"/artists/john-lapides-work-2.jpg",
|
||||||
|
"/artists/john-lapides-work-3.jpg"
|
||||||
|
],
|
||||||
|
bio: "John's artistic arsenal is as sharp as his tattoos, specializing in fine line, blackwork, geometric patterns, and neo-traditional styles.",
|
||||||
|
experience: "10 years",
|
||||||
|
rating: 4.9,
|
||||||
|
reviews: 142,
|
||||||
|
availability: "Available",
|
||||||
|
styles: ["Fine Line", "Blackwork", "Geometric", "Neo-Traditional", "Dotwork"],
|
||||||
|
description1: {
|
||||||
|
text: "Sharp and Crisp",
|
||||||
|
details: [
|
||||||
|
"John's artistic arsenal is as sharp as his tattoos, specializing in fine line, blackwork, geometric patterns, and neo-traditional styles.",
|
||||||
|
"Each piece reflects his crisp precision and passion for pushing the boundaries of tattoo artistry."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
slug: "pako-martinez",
|
||||||
|
name: "Pako Martinez",
|
||||||
|
title: "",
|
||||||
|
specialty: "Traditional Artistry",
|
||||||
|
faceImage: "",
|
||||||
|
workImages: [
|
||||||
|
"/artists/pako-martinez-work-1.jpg",
|
||||||
|
"/artists/pako-martinez-work-2.jpg",
|
||||||
|
"/artists/pako-martinez-work-3.jpg"
|
||||||
|
],
|
||||||
|
bio: "Master of traditional tattoo artistry bringing bold lines and vibrant colors to life.",
|
||||||
|
experience: "7 years",
|
||||||
|
rating: 4.6,
|
||||||
|
reviews: 98,
|
||||||
|
availability: "Available",
|
||||||
|
styles: ["Traditional", "American Traditional", "Neo-Traditional", "Color Work"],
|
||||||
|
description1: {
|
||||||
|
text: "Traditional Master",
|
||||||
|
details: [
|
||||||
|
"Pako brings traditional tattoo artistry to life with bold lines and vibrant colors.",
|
||||||
|
"Specializing in American traditional and neo-traditional styles."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
slug: "steven-sole-cedre",
|
||||||
|
name: "Steven 'Sole' Cedre",
|
||||||
|
title: "It has to have soul, Sole!",
|
||||||
|
specialty: "Gritty Realism & Comic Art",
|
||||||
|
faceImage: "/artists/steven-sole-cedre.jpg",
|
||||||
|
workImages: [
|
||||||
|
"/artists/sole-cedre-work-1.jpg",
|
||||||
|
"/artists/sole-cedre-work-2.jpg",
|
||||||
|
"/artists/sole-cedre-work-3.jpg",
|
||||||
|
"/artists/sole-cedre-work-4.jpg"
|
||||||
|
],
|
||||||
|
bio: "Embark on an epic journey with Steven 'Sole' Cedre, a creative force with over three decades of electrifying artistry.",
|
||||||
|
experience: "30+ years",
|
||||||
|
rating: 5.0,
|
||||||
|
reviews: 287,
|
||||||
|
availability: "Limited slots",
|
||||||
|
styles: ["Realism", "Comic Book", "Black & Grey", "Portraits", "Illustrative"],
|
||||||
|
description1: {
|
||||||
|
text: "It has to have soul, Sole!",
|
||||||
|
details: [
|
||||||
|
"Embark on an epic journey with Steven 'Sole' Cedre, a creative force with over three decades of electrifying artistry.",
|
||||||
|
"Fusing gritty realism with bold, comic book-inspired designs, Sole's tattoos are a dynamic celebration of storytelling and imagination."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
slug: "deziree-stanford",
|
||||||
|
name: "Deziree Stanford",
|
||||||
|
title: "",
|
||||||
|
specialty: "Apprentice Artist",
|
||||||
|
faceImage: "",
|
||||||
|
workImages: [],
|
||||||
|
bio: "Passionate apprentice artist bringing fresh creativity and dedication to every piece.",
|
||||||
|
experience: "Apprentice",
|
||||||
|
rating: 4.5,
|
||||||
|
reviews: 0,
|
||||||
|
availability: "Available",
|
||||||
|
styles: ["Traditional", "Black & Grey", "Fine Line"],
|
||||||
|
description1: {
|
||||||
|
text: "Emerging Talent",
|
||||||
|
details: [
|
||||||
|
"Deziree is our talented apprentice, learning the craft of tattooing under expert guidance.",
|
||||||
|
"Bringing enthusiasm and artistic passion to United Tattoo."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
slug: "kaori-cedre",
|
||||||
|
name: "Kaori Cedre",
|
||||||
|
title: "",
|
||||||
|
specialty: "Artistic Expression",
|
||||||
|
faceImage: "",
|
||||||
|
workImages: [],
|
||||||
|
bio: "Skilled tattoo artist bringing creativity and precision to every design.",
|
||||||
|
experience: "5+ years",
|
||||||
|
rating: 4.8,
|
||||||
|
reviews: 0,
|
||||||
|
availability: "Available",
|
||||||
|
styles: ["Black & Grey", "Fine Line", "Illustrative", "Color Work"],
|
||||||
|
description1: {
|
||||||
|
text: "Creative Vision",
|
||||||
|
details: [
|
||||||
|
"Kaori brings artistic vision and technical skill to United Tattoo.",
|
||||||
|
"Specializing in designs that blend precision with creative expression."
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
slug: "amari-rodriguez",
|
|
||||||
name: "Amari Rodriguez",
|
|
||||||
title: "",
|
|
||||||
specialty: "Apprentice Artist",
|
|
||||||
faceImage: "/artists/amari-rodriguez-portrait.jpg",
|
|
||||||
workImages: [
|
|
||||||
"/artists/amari-rodriguez-work-1.jpg",
|
|
||||||
"/artists/amari-rodriguez-work-2.jpg",
|
|
||||||
"/artists/amari-rodriguez-work-3.jpg"
|
|
||||||
],
|
|
||||||
bio: "Passionate apprentice artist bringing fresh creativity and dedication to every piece.",
|
|
||||||
experience: "Apprentice",
|
|
||||||
rating: 4.5,
|
|
||||||
reviews: 12,
|
|
||||||
availability: "Available",
|
|
||||||
styles: ["Traditional", "Color Work", "Black & Grey", "Fine Line"],
|
|
||||||
description1: {
|
|
||||||
text: "Rising Talent",
|
|
||||||
details: [
|
|
||||||
"Amari is our talented apprentice, training under the guidance of Christy Lumberg.",
|
|
||||||
"Bringing fresh perspectives and passionate dedication to the art of tattooing."
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
slug: "donovan-lankford",
|
|
||||||
name: "Donovan Lankford",
|
|
||||||
title: "",
|
|
||||||
specialty: "Boldly Illustrated",
|
|
||||||
faceImage: "/artists/donovan-lankford-portrait.jpg",
|
|
||||||
workImages: [
|
|
||||||
"/artists/donovan-lankford-work-1.jpg",
|
|
||||||
"/artists/donovan-lankford-work-2.jpg",
|
|
||||||
"/artists/donovan-lankford-work-3.jpg",
|
|
||||||
"/artists/donovan-lankford-work-4.jpg"
|
|
||||||
],
|
|
||||||
bio: "Donovan's artistry seamlessly merges bold and intricate illustrative details, infusing each tattoo with unparalleled passion and creativity.",
|
|
||||||
experience: "8 years",
|
|
||||||
rating: 4.9,
|
|
||||||
reviews: 167,
|
|
||||||
availability: "Available",
|
|
||||||
styles: ["Anime", "Illustrative", "Black & Grey", "Dotwork", "Neo-Traditional"],
|
|
||||||
description1: {
|
|
||||||
text: "Boldly Illustrated",
|
|
||||||
details: [
|
|
||||||
"Donovan's artistry seamlessly merges bold and intricate illustrative details, infusing each tattoo with unparalleled passion and creativity.",
|
|
||||||
"From anime-inspired designs to striking black and grey illustrative work and meticulous dotwork, his versatility brings every vision to life."
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
slug: "efrain-ej-segoviano",
|
|
||||||
name: "Efrain 'EJ' Segoviano",
|
|
||||||
title: "",
|
|
||||||
specialty: "Evolving Boldly",
|
|
||||||
faceImage: "/artists/ej-segoviano-portrait.jpg",
|
|
||||||
workImages: [
|
|
||||||
"/artists/ej-segoviano-work-1.jpg",
|
|
||||||
"/artists/ej-segoviano-work-2.jpg",
|
|
||||||
"/artists/ej-segoviano-work-3.jpg"
|
|
||||||
],
|
|
||||||
bio: "EJ is a self-taught tattoo artist redefining creativity with fresh perspectives and undeniable skill.",
|
|
||||||
experience: "6 years",
|
|
||||||
rating: 4.7,
|
|
||||||
reviews: 93,
|
|
||||||
availability: "Available",
|
|
||||||
styles: ["Black & Grey", "High Contrast", "Realism", "Illustrative"],
|
|
||||||
description1: {
|
|
||||||
text: "Evolving Boldly",
|
|
||||||
details: [
|
|
||||||
"EJ is a self-taught tattoo artist redefining creativity with fresh perspectives and undeniable skill.",
|
|
||||||
"A rising star in the industry, his high-contrast black and grey designs showcase a bold, evolving artistry that leaves a lasting impression."
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
slug: "heather-santistevan",
|
|
||||||
name: "Heather Santistevan",
|
|
||||||
title: "",
|
|
||||||
specialty: "Art in Motion",
|
|
||||||
faceImage: "/artists/heather-santistevan-portrait.jpg",
|
|
||||||
workImages: [
|
|
||||||
"/artists/heather-santistevan-work-1.jpg",
|
|
||||||
"/artists/heather-santistevan-work-2.jpg",
|
|
||||||
"/artists/heather-santistevan-work-3.jpg",
|
|
||||||
"/artists/heather-santistevan-work-4.jpg"
|
|
||||||
],
|
|
||||||
bio: "With a creative journey spanning since 2012, Heather brings unmatched artistry to the tattoo world.",
|
|
||||||
experience: "12+ years",
|
|
||||||
rating: 4.8,
|
|
||||||
reviews: 178,
|
|
||||||
availability: "Limited slots",
|
|
||||||
styles: ["Watercolor", "Embroidery Style", "Patchwork", "Illustrative", "Color Work"],
|
|
||||||
description1: {
|
|
||||||
text: "Art in Motion",
|
|
||||||
details: [
|
|
||||||
"With a creative journey spanning since 2012, Heather Santistevan brings unmatched artistry to the tattoo world.",
|
|
||||||
"Specializing in vibrant watercolor designs and intricate embroidery-style patchwork, her work turns skin into stunning, wearable art."
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 7,
|
|
||||||
slug: "john-lapides",
|
|
||||||
name: "John Lapides",
|
|
||||||
title: "",
|
|
||||||
specialty: "Sharp and Crisp",
|
|
||||||
faceImage: "/artists/john-lapides-portrait.jpg",
|
|
||||||
workImages: [
|
|
||||||
"/artists/john-lapides-work-1.jpg",
|
|
||||||
"/artists/john-lapides-work-2.jpg",
|
|
||||||
"/artists/john-lapides-work-3.jpg"
|
|
||||||
],
|
|
||||||
bio: "John's artistic arsenal is as sharp as his tattoos, specializing in fine line, blackwork, geometric patterns, and neo-traditional styles.",
|
|
||||||
experience: "10 years",
|
|
||||||
rating: 4.9,
|
|
||||||
reviews: 142,
|
|
||||||
availability: "Available",
|
|
||||||
styles: ["Fine Line", "Blackwork", "Geometric", "Neo-Traditional", "Dotwork"],
|
|
||||||
description1: {
|
|
||||||
text: "Sharp and Crisp",
|
|
||||||
details: [
|
|
||||||
"John's artistic arsenal is as sharp as his tattoos, specializing in fine line, blackwork, geometric patterns, and neo-traditional styles.",
|
|
||||||
"Each piece reflects his crisp precision and passion for pushing the boundaries of tattoo artistry."
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 8,
|
|
||||||
slug: "pako-martinez",
|
|
||||||
name: "Pako Martinez",
|
|
||||||
title: "",
|
|
||||||
specialty: "Traditional Artistry",
|
|
||||||
faceImage: "/artists/pako-martinez-portrait.jpg",
|
|
||||||
workImages: [
|
|
||||||
"/artists/pako-martinez-work-1.jpg",
|
|
||||||
"/artists/pako-martinez-work-2.jpg",
|
|
||||||
"/artists/pako-martinez-work-3.jpg"
|
|
||||||
],
|
|
||||||
bio: "Master of traditional tattoo artistry bringing bold lines and vibrant colors to life.",
|
|
||||||
experience: "7 years",
|
|
||||||
rating: 4.6,
|
|
||||||
reviews: 98,
|
|
||||||
availability: "Available",
|
|
||||||
styles: ["Traditional", "American Traditional", "Neo-Traditional", "Color Work"],
|
|
||||||
description1: {
|
|
||||||
text: "Traditional Master",
|
|
||||||
details: [
|
|
||||||
"Pako brings traditional tattoo artistry to life with bold lines and vibrant colors.",
|
|
||||||
"Specializing in American traditional and neo-traditional styles."
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 9,
|
|
||||||
slug: "steven-sole-cedre",
|
|
||||||
name: "Steven 'Sole' Cedre",
|
|
||||||
title: "It has to have soul, Sole!",
|
|
||||||
specialty: "Gritty Realism & Comic Art",
|
|
||||||
faceImage: "/artists/steven-sole-cedre.jpg",
|
|
||||||
workImages: [
|
|
||||||
"/artists/sole-cedre-work-1.jpg",
|
|
||||||
"/artists/sole-cedre-work-2.jpg",
|
|
||||||
"/artists/sole-cedre-work-3.jpg",
|
|
||||||
"/artists/sole-cedre-work-4.jpg"
|
|
||||||
],
|
|
||||||
bio: "Embark on an epic journey with Steven 'Sole' Cedre, a creative force with over three decades of electrifying artistry.",
|
|
||||||
experience: "30+ years",
|
|
||||||
rating: 5.0,
|
|
||||||
reviews: 287,
|
|
||||||
availability: "Limited slots",
|
|
||||||
styles: ["Realism", "Comic Book", "Black & Grey", "Portraits", "Illustrative"],
|
|
||||||
description1: {
|
|
||||||
text: "It has to have soul, Sole!",
|
|
||||||
details: [
|
|
||||||
"Embark on an epic journey with Steven 'Sole' Cedre, a creative force with over three decades of electrifying artistry.",
|
|
||||||
"Fusing gritty realism with bold, comic book-inspired designs, Sole's tattoos are a dynamic celebration of storytelling and imagination."
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export const getArtistById = (id: number): Artist | undefined => {
|
export const getArtistById = (id: number): Artist | undefined => {
|
||||||
return artists.find(artist => artist.id === id)
|
return artists.find(artist => artist.id === id)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getArtistBySlug = (slug: string): Artist | undefined => {
|
export const getArtistBySlug = (slug: string): Artist | undefined => {
|
||||||
return artists.find(artist => artist.slug === slug)
|
return artists.find(artist => artist.slug === slug)
|
||||||
}
|
}
|
||||||
|
|||||||
1144
docs/BOOKING-WORKFLOW-FINAL-PLAN.md
Normal file
724
docs/BOOKING-WORKFLOW-REVISED-PLAN.md
Normal file
@ -0,0 +1,724 @@
|
|||||||
|
# Booking Workflow - Revised Implementation Plan
|
||||||
|
|
||||||
|
**Version:** 2.0 (Revised with Nextcloud OAuth2)
|
||||||
|
**Date:** January 9, 2025
|
||||||
|
**Status:** Ready for Implementation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical Architectural Decisions
|
||||||
|
|
||||||
|
### 1. Authentication Strategy: TWO-TIER SYSTEM
|
||||||
|
|
||||||
|
**Customers (Public):**
|
||||||
|
- ❌ NO LOGIN REQUIRED
|
||||||
|
- ✅ Anonymous booking with email + phone
|
||||||
|
- ✅ Receive confirmation via email
|
||||||
|
- ✅ Simple, fast, no friction
|
||||||
|
|
||||||
|
**Artists & Admins (Internal):**
|
||||||
|
- ✅ Nextcloud OAuth2 authentication
|
||||||
|
- ✅ Single sign-on (SSO)
|
||||||
|
- ✅ Access to admin dashboards + calendars
|
||||||
|
|
||||||
|
### 2. Artist Data: HYBRID MODEL (No Migration Needed!)
|
||||||
|
|
||||||
|
**Cloudflare D1 + R2:**
|
||||||
|
- ✅ Source of truth for artist PROFILES
|
||||||
|
- ✅ Name, bio, specialties, portfolio images
|
||||||
|
- ✅ Used by public website (existing code stays as-is)
|
||||||
|
- ✅ Booking form artist selection
|
||||||
|
- ✅ **NO CHANGES TO EXISTING SYSTEM**
|
||||||
|
|
||||||
|
**Nextcloud:**
|
||||||
|
- ✅ Source of truth for AUTHENTICATION
|
||||||
|
- ✅ Source of truth for CALENDAR availability
|
||||||
|
- ✅ Artists are users in "artist" Nextcloud group
|
||||||
|
|
||||||
|
**Link Between Them:**
|
||||||
|
- Simple: Match via email address
|
||||||
|
- Robust: Add optional `nextcloud_user_id` to artists table
|
||||||
|
- No complex sync needed!
|
||||||
|
|
||||||
|
### 3. Services Confirmed
|
||||||
|
|
||||||
|
**Email:** Resend (free tier - 3,000/month)
|
||||||
|
- Domain: `united-tattoos.com` (owned, on Cloudflare)
|
||||||
|
- Sender: `bookings@united-tattoos.com`
|
||||||
|
- Easy ownership transfer via team feature
|
||||||
|
|
||||||
|
**Monitoring:**
|
||||||
|
- Cloudflare Workers Analytics (free, built-in)
|
||||||
|
- Sentry (free tier - 5k errors/month)
|
||||||
|
|
||||||
|
**Authentication:**
|
||||||
|
- Nextcloud OAuth2 (already configured!)
|
||||||
|
- Client ID: `PZmqmi9vxYjtyWzt7f8QZk61jtwoAaqZ5bZz6wLvYUu4lYc0PPY6cx9qcBgDh5QI`
|
||||||
|
- Secret: `tkf7Ytc4vQII47OhumKBl3O3p6WhiPFQBzb5DJhw7ZjmJwDE0zTGwYGwF0MJjcsm`
|
||||||
|
- Base URL: `https://portal.united-tattoos.com`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Revised Implementation Phases
|
||||||
|
|
||||||
|
## PHASE 0: Foundation Setup (NEW - FIRST!)
|
||||||
|
|
||||||
|
**Duration:** 1-2 days
|
||||||
|
**Priority:** BLOCKING - Must complete before other phases
|
||||||
|
|
||||||
|
### 0.1 Set Up Resend Email Service
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Sign up at resend.com (free tier)
|
||||||
|
2. Add domain `united-tattoos.com`
|
||||||
|
3. Add DNS records to Cloudflare:
|
||||||
|
```
|
||||||
|
TXT resend._domainkey [value from Resend]
|
||||||
|
TXT _dmarc "v=DMARC1; p=none;"
|
||||||
|
```
|
||||||
|
4. Verify domain
|
||||||
|
5. Test send email
|
||||||
|
6. Add API key to environment variables
|
||||||
|
|
||||||
|
**Environment Variable:**
|
||||||
|
```env
|
||||||
|
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 0.2 Configure Nextcloud OAuth2 Provider
|
||||||
|
|
||||||
|
**Already Done! Just need to integrate:**
|
||||||
|
|
||||||
|
**Update `lib/auth.ts` (NextAuth config):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import NextAuth, { NextAuthOptions } from "next-auth"
|
||||||
|
import { D1Adapter } from "@next-auth/d1-adapter"
|
||||||
|
|
||||||
|
export const authOptions: NextAuthOptions = {
|
||||||
|
adapter: D1Adapter(process.env.DB), // Existing D1 adapter
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
id: "nextcloud",
|
||||||
|
name: "Nextcloud",
|
||||||
|
type: "oauth",
|
||||||
|
authorization: {
|
||||||
|
url: "https://portal.united-tattoos.com/apps/oauth2/authorize",
|
||||||
|
params: { scope: "openid profile email" },
|
||||||
|
},
|
||||||
|
token: "https://portal.united-tattoos.com/apps/oauth2/api/v1/token",
|
||||||
|
userinfo: "https://portal.united-tattoos.com/ocs/v2.php/cloud/user?format=json",
|
||||||
|
clientId: process.env.NEXTCLOUD_CLIENT_ID,
|
||||||
|
clientSecret: process.env.NEXTCLOUD_CLIENT_SECRET,
|
||||||
|
profile(profile) {
|
||||||
|
return {
|
||||||
|
id: profile.ocs.data.id,
|
||||||
|
name: profile.ocs.data.displayname,
|
||||||
|
email: profile.ocs.data.email,
|
||||||
|
image: profile.ocs.data.avatar || null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
callbacks: {
|
||||||
|
async session({ session, user }) {
|
||||||
|
// Add user role from database
|
||||||
|
const db = getDB()
|
||||||
|
const dbUser = await db
|
||||||
|
.prepare('SELECT role FROM users WHERE email = ?')
|
||||||
|
.bind(session.user.email)
|
||||||
|
.first()
|
||||||
|
|
||||||
|
session.user.id = user.id
|
||||||
|
session.user.role = dbUser?.role || 'CLIENT'
|
||||||
|
|
||||||
|
return session
|
||||||
|
},
|
||||||
|
async signIn({ user, account, profile }) {
|
||||||
|
// Check if user is in Nextcloud "artist" or "admin" group
|
||||||
|
// This can be checked via Nextcloud API if needed
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
signIn: '/auth/signin',
|
||||||
|
error: '/auth/error',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Environment Variables:**
|
||||||
|
```env
|
||||||
|
NEXTCLOUD_CLIENT_ID=PZmqmi9vxYjtyWzt7f8QZk61jtwoAaqZ5bZz6wLvYUu4lYc0PPY6cx9qcBgDh5QI
|
||||||
|
NEXTCLOUD_CLIENT_SECRET=tkf7Ytc4vQII47OhumKBl3O3p6WhiPFQBzb5DJhw7ZjmJwDE0zTGwYGwF0MJjcsm
|
||||||
|
NEXTCLOUD_BASE_URL=https://portal.united-tattoos.com
|
||||||
|
```
|
||||||
|
|
||||||
|
**Callback URL (already configured in Nextcloud):**
|
||||||
|
- Production: `https://united-tattoos.com/api/auth/callback/nextcloud`
|
||||||
|
- Dev: `http://localhost:3000/api/auth/callback/nextcloud`
|
||||||
|
|
||||||
|
### 0.3 Create Admin Middleware
|
||||||
|
|
||||||
|
**File: `middleware.ts`** (Update existing or create)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { withAuth } from "next-auth/middleware"
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
|
||||||
|
export default withAuth(
|
||||||
|
function middleware(req) {
|
||||||
|
const token = req.nextauth.token
|
||||||
|
const path = req.nextUrl.pathname
|
||||||
|
|
||||||
|
// Admin routes require SHOP_ADMIN or SUPER_ADMIN role
|
||||||
|
if (path.startsWith('/admin')) {
|
||||||
|
if (!token || (token.role !== 'SHOP_ADMIN' && token.role !== 'SUPER_ADMIN')) {
|
||||||
|
return NextResponse.redirect(new URL('/auth/signin?callbackUrl=' + path, req.url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Artist dashboard requires ARTIST role
|
||||||
|
if (path.startsWith('/artist-dashboard')) {
|
||||||
|
if (!token || token.role !== 'ARTIST') {
|
||||||
|
return NextResponse.redirect(new URL('/auth/signin?callbackUrl=' + path, req.url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
callbacks: {
|
||||||
|
authorized: ({ token, req }) => {
|
||||||
|
// Allow all API routes (they handle their own auth)
|
||||||
|
if (req.nextUrl.pathname.startsWith('/api')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require auth for admin/artist routes
|
||||||
|
if (req.nextUrl.pathname.startsWith('/admin') ||
|
||||||
|
req.nextUrl.pathname.startsWith('/artist-dashboard')) {
|
||||||
|
return !!token
|
||||||
|
}
|
||||||
|
|
||||||
|
// All other routes are public
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/admin/:path*', '/artist-dashboard/:path*', '/api/:path*'],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 0.4 Add Sentry Error Tracking
|
||||||
|
|
||||||
|
**Install:**
|
||||||
|
```bash
|
||||||
|
npm install @sentry/nextjs
|
||||||
|
```
|
||||||
|
|
||||||
|
**File: `sentry.client.config.js`**
|
||||||
|
```javascript
|
||||||
|
import * as Sentry from "@sentry/nextjs"
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
|
tracesSampleRate: 0.1,
|
||||||
|
environment: process.env.NODE_ENV,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Environment Variable:**
|
||||||
|
```env
|
||||||
|
NEXT_PUBLIC_SENTRY_DSN=https://xxxxx@xxxxx.ingest.sentry.io/xxxxx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 1: Customer Booking Flow (REVISED)
|
||||||
|
|
||||||
|
**Duration:** 3-4 days
|
||||||
|
**Changes:** No customer authentication required!
|
||||||
|
|
||||||
|
### 1.1 Update Database Schema for Anonymous Bookings
|
||||||
|
|
||||||
|
**Add to migrations:**
|
||||||
|
```sql
|
||||||
|
-- Allow client_id to be NULL for anonymous bookings
|
||||||
|
-- Store email/phone directly on appointment
|
||||||
|
ALTER TABLE appointments ADD COLUMN client_email TEXT;
|
||||||
|
ALTER TABLE appointments ADD COLUMN client_phone TEXT;
|
||||||
|
ALTER TABLE appointments ADD COLUMN client_name TEXT;
|
||||||
|
|
||||||
|
-- Make client_id optional
|
||||||
|
-- (In SQLite, we'd need to recreate the table, but for now just allow NULL in app logic)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 Update Booking Form (No Login Required!)
|
||||||
|
|
||||||
|
**File: `components/booking-form.tsx`**
|
||||||
|
|
||||||
|
Remove all session checks! Update to:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!bookingEnabled) {
|
||||||
|
toast.error('Booking temporarily unavailable')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// NO SESSION CHECK - Customers book anonymously!
|
||||||
|
|
||||||
|
if (!selectedArtist) {
|
||||||
|
toast.error('Please select an artist')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!appointmentStart || !appointmentEnd) {
|
||||||
|
toast.error('Please select a date, time, and tattoo size')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create booking without authentication
|
||||||
|
createBooking.mutate({
|
||||||
|
artistId: selectedArtist.id,
|
||||||
|
// No clientId - this is anonymous!
|
||||||
|
clientName: `${formData.firstName} ${formData.lastName}`,
|
||||||
|
clientEmail: formData.email,
|
||||||
|
clientPhone: formData.phone,
|
||||||
|
title: `Tattoo: ${formData.tattooDescription.substring(0, 50)}`,
|
||||||
|
description: formData.tattooDescription,
|
||||||
|
startTime: appointmentStart,
|
||||||
|
endTime: appointmentEnd,
|
||||||
|
depositAmount: formData.depositAmount,
|
||||||
|
notes: formData.specialRequests,
|
||||||
|
}, {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
router.push(`/book/confirm/${data.appointment.id}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 Update Appointments API for Anonymous Bookings
|
||||||
|
|
||||||
|
**File: `app/api/appointments/route.ts`** (UPDATE)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const createAppointmentSchema = z.object({
|
||||||
|
artistId: z.string().min(1),
|
||||||
|
clientId: z.string().optional(), // Now optional!
|
||||||
|
clientName: z.string().min(1), // NEW - Required
|
||||||
|
clientEmail: z.string().email(), // NEW - Required
|
||||||
|
clientPhone: z.string().min(1), // NEW - Required
|
||||||
|
title: z.string().min(1),
|
||||||
|
description: z.string().optional(),
|
||||||
|
startTime: z.string().datetime(),
|
||||||
|
endTime: z.string().datetime(),
|
||||||
|
depositAmount: z.number().optional(),
|
||||||
|
totalAmount: z.number().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, ...) {
|
||||||
|
try {
|
||||||
|
// NO AUTHENTICATION CHECK for booking creation!
|
||||||
|
// This is intentionally public for customer bookings
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const validatedData = createAppointmentSchema.parse(body)
|
||||||
|
|
||||||
|
const db = getDB(context?.env)
|
||||||
|
|
||||||
|
// Check CalDAV availability (Nextcloud is source of truth)
|
||||||
|
const startDate = new Date(validatedData.startTime)
|
||||||
|
const endDate = new Date(validatedData.endTime)
|
||||||
|
|
||||||
|
const availabilityCheck = await checkArtistAvailability(
|
||||||
|
validatedData.artistId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
context
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!availabilityCheck.available) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Time slot not available',
|
||||||
|
reason: availabilityCheck.reason || 'Selected time slot conflicts with existing booking.'
|
||||||
|
},
|
||||||
|
{ status: 409 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const appointmentId = crypto.randomUUID()
|
||||||
|
|
||||||
|
// Create appointment with customer contact info
|
||||||
|
const insertStmt = db.prepare(`
|
||||||
|
INSERT INTO appointments (
|
||||||
|
id, artist_id, client_id, client_name, client_email, client_phone,
|
||||||
|
title, description, start_time, end_time,
|
||||||
|
status, deposit_amount, total_amount, notes, created_at, updated_at
|
||||||
|
) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
`)
|
||||||
|
|
||||||
|
await insertStmt.bind(
|
||||||
|
appointmentId,
|
||||||
|
validatedData.artistId,
|
||||||
|
validatedData.clientName,
|
||||||
|
validatedData.clientEmail,
|
||||||
|
validatedData.clientPhone,
|
||||||
|
validatedData.title,
|
||||||
|
validatedData.description || null,
|
||||||
|
validatedData.startTime,
|
||||||
|
validatedData.endTime,
|
||||||
|
validatedData.depositAmount || null,
|
||||||
|
validatedData.totalAmount || null,
|
||||||
|
validatedData.notes || null
|
||||||
|
).run()
|
||||||
|
|
||||||
|
// Fetch the created appointment
|
||||||
|
const appointment = await db.prepare(`
|
||||||
|
SELECT
|
||||||
|
a.*,
|
||||||
|
ar.name as artist_name
|
||||||
|
FROM appointments a
|
||||||
|
JOIN artists ar ON a.artist_id = ar.id
|
||||||
|
WHERE a.id = ?
|
||||||
|
`).bind(appointmentId).first()
|
||||||
|
|
||||||
|
// Sync to CalDAV
|
||||||
|
try {
|
||||||
|
await syncAppointmentToCalendar(appointment as any, context)
|
||||||
|
} catch (syncError) {
|
||||||
|
console.error('Failed to sync to calendar:', syncError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send email confirmation to customer
|
||||||
|
try {
|
||||||
|
await sendBookingConfirmationEmail(appointment as any)
|
||||||
|
} catch (emailError) {
|
||||||
|
console.error('Failed to send confirmation email:', emailError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ appointment }, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
// ... error handling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 Create Email Service
|
||||||
|
|
||||||
|
**File: `lib/email.ts`**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Resend } from 'resend'
|
||||||
|
|
||||||
|
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||||
|
|
||||||
|
export async function sendBookingConfirmationEmail(appointment: {
|
||||||
|
id: string
|
||||||
|
client_name: string
|
||||||
|
client_email: string
|
||||||
|
artist_name: string
|
||||||
|
start_time: string
|
||||||
|
description: string
|
||||||
|
}) {
|
||||||
|
const startTime = new Date(appointment.start_time)
|
||||||
|
const formattedDate = new Intl.DateTimeFormat('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
timeZone: 'America/Denver',
|
||||||
|
}).format(startTime)
|
||||||
|
|
||||||
|
const formattedTime = new Intl.DateTimeFormat('en-US', {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZoneName: 'short',
|
||||||
|
timeZone: 'America/Denver',
|
||||||
|
}).format(startTime)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await resend.emails.send({
|
||||||
|
from: 'United Tattoo <bookings@united-tattoos.com>',
|
||||||
|
to: appointment.client_email,
|
||||||
|
subject: 'Your Tattoo Booking Request - United Tattoo',
|
||||||
|
html: `
|
||||||
|
<h1>Booking Request Received!</h1>
|
||||||
|
<p>Hi ${appointment.client_name},</p>
|
||||||
|
<p>We've received your tattoo appointment request. Here are the details:</p>
|
||||||
|
|
||||||
|
<div style="background: #f5f5f5; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<p><strong>Artist:</strong> ${appointment.artist_name}</p>
|
||||||
|
<p><strong>Date:</strong> ${formattedDate}</p>
|
||||||
|
<p><strong>Time:</strong> ${formattedTime}</p>
|
||||||
|
<p><strong>Description:</strong> ${appointment.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>What's Next?</h2>
|
||||||
|
<ol>
|
||||||
|
<li>Your artist will review your request (usually within 24 hours)</li>
|
||||||
|
<li>You'll receive an email when your appointment is confirmed</li>
|
||||||
|
<li>Bring a valid ID and arrive 10 minutes early</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<p>Questions? Call us at (719) 555-1234 or reply to this email.</p>
|
||||||
|
|
||||||
|
<p>Thanks,<br>United Tattoo Team</p>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Email send failed:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendBookingStatusChangeEmail(appointment: {
|
||||||
|
client_name: string
|
||||||
|
client_email: string
|
||||||
|
artist_name: string
|
||||||
|
start_time: string
|
||||||
|
status: string
|
||||||
|
}) {
|
||||||
|
const statusMessages = {
|
||||||
|
CONFIRMED: {
|
||||||
|
subject: 'Your Tattoo Appointment is Confirmed!',
|
||||||
|
message: 'Great news! Your appointment has been confirmed.',
|
||||||
|
action: 'You can now pay your deposit at the shop or via the link below.',
|
||||||
|
},
|
||||||
|
CANCELLED: {
|
||||||
|
subject: 'Appointment Update - United Tattoo',
|
||||||
|
message: 'Unfortunately, we need to reschedule your appointment.',
|
||||||
|
action: 'Please contact us to find a new time that works for you.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = statusMessages[appointment.status as keyof typeof statusMessages]
|
||||||
|
if (!config) return // Don't send email for other statuses
|
||||||
|
|
||||||
|
try {
|
||||||
|
await resend.emails.send({
|
||||||
|
from: 'United Tattoo <bookings@united-tattoos.com>',
|
||||||
|
to: appointment.client_email,
|
||||||
|
subject: config.subject,
|
||||||
|
html: `
|
||||||
|
<h1>${config.message}</h1>
|
||||||
|
<p>Hi ${appointment.client_name},</p>
|
||||||
|
<p><strong>Artist:</strong> ${appointment.artist_name}</p>
|
||||||
|
<p><strong>Status:</strong> ${appointment.status}</p>
|
||||||
|
<p>${config.action}</p>
|
||||||
|
<p>Questions? Call us at (719) 555-1234</p>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Status change email failed:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.5 Update Confirmation Page (No Auth Required)
|
||||||
|
|
||||||
|
**File: `app/book/confirm/[id]/page.tsx`**
|
||||||
|
|
||||||
|
Remove authentication check - make it public with just the booking ID:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// NO getServerSession call!
|
||||||
|
// Anyone with the link can view their confirmation
|
||||||
|
|
||||||
|
async function getBooking(id: string) {
|
||||||
|
const db = getDB()
|
||||||
|
|
||||||
|
const booking = await db.prepare(`
|
||||||
|
SELECT
|
||||||
|
a.*,
|
||||||
|
ar.name as artist_name,
|
||||||
|
ar.instagram_handle
|
||||||
|
FROM appointments a
|
||||||
|
JOIN artists ar ON a.artist_id = ar.id
|
||||||
|
WHERE a.id = ?
|
||||||
|
`).bind(id).first()
|
||||||
|
|
||||||
|
return booking
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BookingConfirmationPage({ params }) {
|
||||||
|
const booking = await getBooking(params.id)
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
// No auth check - confirmation is public!
|
||||||
|
// Security by obscurity (UUID is hard to guess)
|
||||||
|
|
||||||
|
return (
|
||||||
|
// ... existing confirmation page content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 2 & 3: Admin Dashboards (PROTECTED)
|
||||||
|
|
||||||
|
**These stay mostly the same, but NOW:**
|
||||||
|
- ✅ Protected by middleware
|
||||||
|
- ✅ Require Nextcloud OAuth2 login
|
||||||
|
- ✅ Check user role from database
|
||||||
|
|
||||||
|
All the code from the original plan applies here, just add middleware protection!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Artist Data Linking Strategy
|
||||||
|
|
||||||
|
### How to Link D1 Artists with Nextcloud Users
|
||||||
|
|
||||||
|
**Option 1: Email Matching (Simple - Start Here)**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// When admin configures calendar, we match by email
|
||||||
|
async function linkArtistToNextcloud(artistId: string) {
|
||||||
|
const db = getDB()
|
||||||
|
|
||||||
|
// Get artist email from D1
|
||||||
|
const artist = await db
|
||||||
|
.prepare('SELECT email FROM artists WHERE id = ?')
|
||||||
|
.bind(artistId)
|
||||||
|
.first()
|
||||||
|
|
||||||
|
// Calendar URL pattern for this artist
|
||||||
|
const calendarUrl = `https://portal.united-tattoos.com/remote.php/dav/calendars/${artist.email}/personal/`
|
||||||
|
|
||||||
|
// Save configuration
|
||||||
|
await db.prepare(`
|
||||||
|
INSERT INTO artist_calendars (id, artist_id, calendar_url, calendar_id)
|
||||||
|
VALUES (?, ?, ?, 'personal')
|
||||||
|
`).bind(crypto.randomUUID(), artistId, calendarUrl).run()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 2: Add Nextcloud User ID (Robust - Later)**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Migration: Add optional nextcloud_user_id
|
||||||
|
ALTER TABLE artists ADD COLUMN nextcloud_user_id TEXT;
|
||||||
|
CREATE INDEX idx_artists_nextcloud_user ON artists(nextcloud_user_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
Then query Nextcloud API to get user ID and store it.
|
||||||
|
|
||||||
|
### Migration Path (Non-Breaking!)
|
||||||
|
|
||||||
|
```
|
||||||
|
Current State:
|
||||||
|
✅ D1 artists table exists with all data
|
||||||
|
✅ R2 has portfolio images
|
||||||
|
✅ Website displays artists from D1
|
||||||
|
✅ Booking form uses artists from D1
|
||||||
|
|
||||||
|
Step 1: Create Nextcloud users for each artist
|
||||||
|
- Email must match artists.email in D1
|
||||||
|
- Add to "artist" group in Nextcloud
|
||||||
|
|
||||||
|
Step 2: Artists log in via OAuth2
|
||||||
|
- They use Nextcloud credentials
|
||||||
|
- System matches by email to D1 artist record
|
||||||
|
|
||||||
|
Step 3: Admin links calendars
|
||||||
|
- Use calendar config UI
|
||||||
|
- Matches D1 artist → Nextcloud calendar via email
|
||||||
|
|
||||||
|
✅ No data migration needed
|
||||||
|
✅ Existing site keeps working
|
||||||
|
✅ Just add authentication layer on top
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables Summary
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Existing
|
||||||
|
DATABASE_URL=...
|
||||||
|
NEXTAUTH_URL=https://united-tattoos.com
|
||||||
|
NEXTAUTH_SECRET=...
|
||||||
|
AWS_ACCESS_KEY_ID=...
|
||||||
|
AWS_SECRET_ACCESS_KEY=...
|
||||||
|
AWS_BUCKET_NAME=...
|
||||||
|
|
||||||
|
# NEW - Email
|
||||||
|
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
|
||||||
|
# NEW - Nextcloud OAuth2
|
||||||
|
NEXTCLOUD_CLIENT_ID=PZmqmi9vxYjtyWzt7f8QZk61jtwoAaqZ5bZz6wLvYUu4lYc0PPY6cx9qcBgDh5QI
|
||||||
|
NEXTCLOUD_CLIENT_SECRET=tkf7Ytc4vQII47OhumKBl3O3p6WhiPFQBzb5DJhw7ZjmJwDE0zTGwYGwF0MJjcsm
|
||||||
|
NEXTCLOUD_BASE_URL=https://portal.united-tattoos.com
|
||||||
|
|
||||||
|
# NEW - CalDAV (for background sync)
|
||||||
|
NEXTCLOUD_USERNAME=admin_or_service_account
|
||||||
|
NEXTCLOUD_PASSWORD=app_password
|
||||||
|
NEXTCLOUD_CALENDAR_BASE_PATH=/remote.php/dav/calendars
|
||||||
|
|
||||||
|
# NEW - Monitoring (optional)
|
||||||
|
NEXT_PUBLIC_SENTRY_DSN=https://xxxxx@xxxxx.ingest.sentry.io/xxxxx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Phase 0 Testing
|
||||||
|
- [ ] Resend email sends successfully
|
||||||
|
- [ ] DNS records verified in Cloudflare
|
||||||
|
- [ ] Nextcloud OAuth2 login works
|
||||||
|
- [ ] Middleware protects admin routes
|
||||||
|
- [ ] Sentry captures test error
|
||||||
|
|
||||||
|
### Phase 1 Testing (No Customer Login)
|
||||||
|
- [ ] Customer can book WITHOUT logging in
|
||||||
|
- [ ] Booking form submits successfully
|
||||||
|
- [ ] Customer receives confirmation email
|
||||||
|
- [ ] Booking syncs to Nextcloud
|
||||||
|
- [ ] Confirmation page accessible via link
|
||||||
|
|
||||||
|
### Admin Testing (With Login)
|
||||||
|
- [ ] Admin can log in via Nextcloud
|
||||||
|
- [ ] Admin sees bookings dashboard
|
||||||
|
- [ ] Admin can approve/reject bookings
|
||||||
|
- [ ] Status changes sync to Nextcloud
|
||||||
|
- [ ] Email sent on status change
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Benefits of This Approach
|
||||||
|
|
||||||
|
✅ **No customer friction** - Book in 2 minutes without account
|
||||||
|
✅ **Single sign-on for staff** - One password (Nextcloud)
|
||||||
|
✅ **No artist data migration** - D1 + R2 stays as-is
|
||||||
|
✅ **Clean separation** - Profiles vs Authentication vs Calendar
|
||||||
|
✅ **Existing site untouched** - All current features keep working
|
||||||
|
✅ **Secure** - Middleware protects admin routes automatically
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Review this plan** - Does the two-tier auth + hybrid data model work for you?
|
||||||
|
2. **Set up Resend** - Add domain, get API key
|
||||||
|
3. **I implement Phase 0** - Foundation (OAuth2 + Email + Middleware)
|
||||||
|
4. **Test authentication** - Make sure Nextcloud login works
|
||||||
|
5. **Proceed with booking flow** - Anonymous customer bookings
|
||||||
|
|
||||||
|
**Ready to proceed?**
|
||||||
|
|
||||||
552
docs/BOOKING-WORKFLOW-RISKS.md
Normal file
@ -0,0 +1,552 @@
|
|||||||
|
# Risk Assessment & Known Issues - Booking Workflow Plan
|
||||||
|
|
||||||
|
**Document Version:** 1.0
|
||||||
|
**Date:** January 9, 2025
|
||||||
|
**Status:** Pre-Implementation Review
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔴 Critical Risks
|
||||||
|
|
||||||
|
### 1. Race Conditions & Concurrency
|
||||||
|
**Risk Level:** HIGH - Could cause double bookings or data loss
|
||||||
|
|
||||||
|
**Issues:**
|
||||||
|
- User books appointment while background sync is running → duplicate or conflicting data
|
||||||
|
- Two admins approve same booking simultaneously → status conflicts
|
||||||
|
- Nextcloud event modified during sync → data inconsistency
|
||||||
|
- No database transaction handling in appointments API
|
||||||
|
|
||||||
|
**Mitigation Required:**
|
||||||
|
- Add database transaction locks for booking creation
|
||||||
|
- Implement optimistic locking with ETags for updates
|
||||||
|
- Add conflict resolution logic with "last write wins" or manual reconciliation
|
||||||
|
- Add unique constraints to prevent duplicates
|
||||||
|
|
||||||
|
**Missing from Plan:** Transaction handling completely absent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Authentication & Authorization Gaps
|
||||||
|
**Risk Level:** HIGH - Security vulnerability
|
||||||
|
|
||||||
|
**Issues:**
|
||||||
|
- Assumption that `session.user.id` exists and matches `appointments.client_id` format
|
||||||
|
- Admin role checking duplicated in every page - error-prone
|
||||||
|
- No middleware protecting admin routes - easy to miss a check
|
||||||
|
- User table schema not verified in plan
|
||||||
|
|
||||||
|
**Mitigation Required:**
|
||||||
|
- Create authentication middleware for all admin routes
|
||||||
|
- Verify user schema has compatible `id` field
|
||||||
|
- Add comprehensive auth tests
|
||||||
|
- Use Next.js middleware for route protection
|
||||||
|
|
||||||
|
**Missing from Plan:** No middleware implementation, schema verification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Background Sync Reliability
|
||||||
|
**Risk Level:** HIGH - Core functionality breaks
|
||||||
|
|
||||||
|
**Issues:**
|
||||||
|
- Worker failures are only logged - no alerts or retries
|
||||||
|
- Nextcloud down = all syncs fail with no recovery
|
||||||
|
- Network timeouts cause partial syncs
|
||||||
|
- 5-minute sync interval = 5-minute lag for critical status changes
|
||||||
|
- No queue for failed operations
|
||||||
|
|
||||||
|
**Mitigation Required:**
|
||||||
|
- Implement retry queue with exponential backoff
|
||||||
|
- Add Cloudflare Workers monitoring/alerting
|
||||||
|
- Create health check endpoint
|
||||||
|
- Consider webhook alternative to reduce lag
|
||||||
|
- Add dead letter queue for permanent failures
|
||||||
|
|
||||||
|
**Missing from Plan:** Retry mechanism, monitoring, alerting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Email Notification Dependency
|
||||||
|
**Risk Level:** HIGH - User communication breaks
|
||||||
|
|
||||||
|
**Issues:**
|
||||||
|
- Entire workflow depends on email but marked as "TODO"
|
||||||
|
- Users/artists never know about status changes without email
|
||||||
|
- SMTP configuration might not be set
|
||||||
|
- No email templates defined
|
||||||
|
- No fallback if email fails
|
||||||
|
|
||||||
|
**Mitigation Required:**
|
||||||
|
- Implement email system BEFORE other phases
|
||||||
|
- Choose email provider (SendGrid, Postmark, AWS SES)
|
||||||
|
- Create email templates
|
||||||
|
- Add in-app notifications as backup
|
||||||
|
- Queue failed emails for retry
|
||||||
|
|
||||||
|
**Missing from Plan:** Email is Phase 3+ but should be Phase 1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟡 Medium Risks
|
||||||
|
|
||||||
|
### 5. Status Detection Brittleness
|
||||||
|
**Risk Level:** MEDIUM - Incorrect status updates
|
||||||
|
|
||||||
|
**Issues:**
|
||||||
|
- Relies on "REQUEST:" prefix - artist could manually edit title
|
||||||
|
- External calendar events could be misidentified as bookings
|
||||||
|
- ical.js might not parse STATUS field correctly
|
||||||
|
- No validation that event belongs to booking system
|
||||||
|
- Magic string "REQUEST:" is hardcoded everywhere
|
||||||
|
|
||||||
|
**Mitigation Required:**
|
||||||
|
- Add unique identifier (UUID) in event description
|
||||||
|
- Validate event source before processing
|
||||||
|
- Add manual reconciliation UI for admins
|
||||||
|
- Move magic strings to constants
|
||||||
|
- Add event ownership verification
|
||||||
|
|
||||||
|
**Missing from Plan:** Event validation, reconciliation UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. CalDAV/Nextcloud Availability
|
||||||
|
**Risk Level:** MEDIUM - Degrades user experience
|
||||||
|
|
||||||
|
**Issues:**
|
||||||
|
- Nextcloud down = slow booking submission (waits for timeout)
|
||||||
|
- CalDAV credentials could expire without notice
|
||||||
|
- Network latency makes availability checks slow (300ms debounce helps but not enough)
|
||||||
|
- Multiple calendars per artist not supported
|
||||||
|
- Calendar URL format might vary by Nextcloud version
|
||||||
|
|
||||||
|
**Mitigation Required:**
|
||||||
|
- Add CalDAV health check endpoint
|
||||||
|
- Implement credential rotation monitoring
|
||||||
|
- Add faster timeout for availability checks (2-3 seconds max)
|
||||||
|
- Cache availability results briefly
|
||||||
|
- Test with different Nextcloud versions
|
||||||
|
|
||||||
|
**Missing from Plan:** Health checks, caching, timeout limits
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Performance & Scalability
|
||||||
|
**Risk Level:** MEDIUM - Won't scale beyond ~50 artists
|
||||||
|
|
||||||
|
**Issues:**
|
||||||
|
- Background worker syncs ALL artists every 5 minutes (expensive)
|
||||||
|
- Fetches 90-day event range every sync (slow with many bookings)
|
||||||
|
- No pagination on bookings DataTable (breaks with 1000+ bookings)
|
||||||
|
- Availability check fires on every form field change
|
||||||
|
- No incremental sync using sync-token
|
||||||
|
|
||||||
|
**Mitigation Required:**
|
||||||
|
- Implement incremental sync with sync-token (CalDAV supports this)
|
||||||
|
- Add pagination to bookings table
|
||||||
|
- Limit event range to 30 days with on-demand expansion
|
||||||
|
- Implement smarter caching for availability
|
||||||
|
- Consider sync only changed calendars
|
||||||
|
|
||||||
|
**Missing from Plan:** Incremental sync, pagination, performance testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. Timezone Edge Cases
|
||||||
|
**Risk Level:** MEDIUM - Wrong-time bookings
|
||||||
|
|
||||||
|
**Issues:**
|
||||||
|
- Hardcoded America/Denver prevents expansion
|
||||||
|
- Daylight Saving Time transitions not tested
|
||||||
|
- Date comparison between systems has timezone bugs potential
|
||||||
|
- User browser timezone vs server vs Nextcloud timezone
|
||||||
|
- No verification that times are displayed correctly
|
||||||
|
|
||||||
|
**Mitigation Required:**
|
||||||
|
- Store all times in UTC internally
|
||||||
|
- Use date-fns-tz for ALL timezone operations
|
||||||
|
- Test DST transitions (spring forward, fall back)
|
||||||
|
- Add timezone to user preferences if expanding
|
||||||
|
- Display timezone clearly in UI
|
||||||
|
|
||||||
|
**Missing from Plan:** DST testing, UTC storage verification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. Data Consistency & Integrity
|
||||||
|
**Risk Level:** MEDIUM - Data quality degrades
|
||||||
|
|
||||||
|
**Issues:**
|
||||||
|
- ETag conflicts if event updated simultaneously
|
||||||
|
- No global unique constraint on `caldav_uid` (only per artist)
|
||||||
|
- `calendar_sync_logs` will grow unbounded
|
||||||
|
- No validation on calendar URL format
|
||||||
|
- No cascade delete handling documented
|
||||||
|
|
||||||
|
**Mitigation Required:**
|
||||||
|
- Add global unique constraint on `caldav_uid`
|
||||||
|
- Implement log rotation (keep last 90 days)
|
||||||
|
- Validate calendar URLs with regex
|
||||||
|
- Add ETag conflict resolution
|
||||||
|
- Document cascade delete behavior
|
||||||
|
|
||||||
|
**Missing from Plan:** Constraints, log rotation, URL validation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟢 Low Risks (Nice to Have)
|
||||||
|
|
||||||
|
### 10. User Experience Gaps
|
||||||
|
**Issues:**
|
||||||
|
- No way to edit booking after submission
|
||||||
|
- No user-facing cancellation flow
|
||||||
|
- Confirmation page doesn't show sync status
|
||||||
|
- No booking history for users
|
||||||
|
- No real-time updates (5-min lag)
|
||||||
|
|
||||||
|
**Mitigation:** Add these as Phase 2 features post-launch
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11. Admin Experience Gaps
|
||||||
|
**Issues:**
|
||||||
|
- No bulk operations in dashboard
|
||||||
|
- No manual reconciliation UI for conflicts
|
||||||
|
- No artist notification preferences
|
||||||
|
- No test connection button (only validates on save)
|
||||||
|
|
||||||
|
**Mitigation:** Add as Phase 3 enhancements
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 12. Testing Coverage
|
||||||
|
**Issues:**
|
||||||
|
- No automated tests (marked TODO)
|
||||||
|
- Manual checklist not integrated into CI/CD
|
||||||
|
- No load testing
|
||||||
|
- No concurrent booking tests
|
||||||
|
|
||||||
|
**Mitigation:** Add comprehensive test suite before production
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 13. Monitoring & Observability
|
||||||
|
**Issues:**
|
||||||
|
- No monitoring for worker failures
|
||||||
|
- Toast errors disappear on navigation
|
||||||
|
- No dashboard for sync health
|
||||||
|
- No Sentry or error tracking
|
||||||
|
|
||||||
|
**Mitigation:** Add monitoring in Phase 4
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 14. Deployment & Operations
|
||||||
|
**Issues:**
|
||||||
|
- Workers cron needs separate deployment
|
||||||
|
- No staging strategy
|
||||||
|
- No migration rollback plan
|
||||||
|
- Environment variables not documented
|
||||||
|
|
||||||
|
**Mitigation:** Create deployment runbook
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Technical Debt & Limitations
|
||||||
|
|
||||||
|
### 15. Architecture Limitations
|
||||||
|
- Single Nextcloud credentials (no per-artist OAuth)
|
||||||
|
- One calendar per artist only
|
||||||
|
- No recurring appointments
|
||||||
|
- No multi-day appointments
|
||||||
|
- No support for artist breaks/vacations
|
||||||
|
|
||||||
|
### 16. Code Quality Issues
|
||||||
|
- Admin role checks duplicated (should be middleware)
|
||||||
|
- Magic strings not in constants
|
||||||
|
- No API versioning
|
||||||
|
- No TypeScript strict mode mentioned
|
||||||
|
|
||||||
|
### 17. Missing Features (Known)
|
||||||
|
- Email notifications (CRITICAL)
|
||||||
|
- Automated tests (CRITICAL)
|
||||||
|
- Background worker deployment (CRITICAL)
|
||||||
|
- Booking edit flow
|
||||||
|
- User cancellation
|
||||||
|
- Webhook support
|
||||||
|
- In-app notifications
|
||||||
|
- SMS option
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 Showstopper Scenarios
|
||||||
|
|
||||||
|
### Scenario 1: Nextcloud Down During Peak Hours
|
||||||
|
**Impact:** Users book but syncs fail → artists don't see bookings
|
||||||
|
**Current Plan:** Fallback to DB-only
|
||||||
|
**Gap:** No retry queue when Nextcloud returns
|
||||||
|
**Required:** Implement sync queue
|
||||||
|
|
||||||
|
### Scenario 2: Background Worker Stops
|
||||||
|
**Impact:** No Nextcloud→Web sync, status changes invisible
|
||||||
|
**Current Plan:** Worker runs but no monitoring
|
||||||
|
**Gap:** No alerts if worker dies
|
||||||
|
**Required:** Health monitoring + alerting
|
||||||
|
|
||||||
|
### Scenario 3: Double Booking
|
||||||
|
**Impact:** Two users book same slot simultaneously
|
||||||
|
**Current Plan:** Availability check before booking
|
||||||
|
**Gap:** Race condition between check and create
|
||||||
|
**Required:** Transaction locks
|
||||||
|
|
||||||
|
### Scenario 4: Email System Down
|
||||||
|
**Impact:** Zero user/artist communication
|
||||||
|
**Current Plan:** Email marked as TODO
|
||||||
|
**Gap:** No fallback communication method
|
||||||
|
**Required:** Email + in-app notifications
|
||||||
|
|
||||||
|
### Scenario 5: DST Transition Bug
|
||||||
|
**Impact:** Appointments booked 1 hour off
|
||||||
|
**Current Plan:** Use date-fns-tz
|
||||||
|
**Gap:** No DST testing mentioned
|
||||||
|
**Required:** DST test suite
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Pre-Launch Checklist
|
||||||
|
|
||||||
|
### ✅ Must-Have (Blocking)
|
||||||
|
1. [ ] Implement email notification system with templates
|
||||||
|
2. [ ] Add authentication middleware for admin routes
|
||||||
|
3. [ ] Implement retry queue for failed syncs
|
||||||
|
4. [ ] Add transaction handling to appointments API
|
||||||
|
5. [ ] Deploy and test background worker
|
||||||
|
6. [ ] Verify timezone handling with DST tests
|
||||||
|
7. [ ] Add monitoring and alerting (Cloudflare Workers analytics + Sentry)
|
||||||
|
8. [ ] Write critical path tests (booking flow, sync flow)
|
||||||
|
9. [ ] Create deployment runbook
|
||||||
|
10. [ ] Set up staging environment with test Nextcloud
|
||||||
|
|
||||||
|
### ⚠️ Should-Have (Important)
|
||||||
|
- [ ] Rate limiting on booking endpoint
|
||||||
|
- [ ] CSRF protection verification
|
||||||
|
- [ ] Calendar URL validation with regex
|
||||||
|
- [ ] Sync log rotation (90-day retention)
|
||||||
|
- [ ] Admin reconciliation UI for conflicts
|
||||||
|
- [ ] User booking history page
|
||||||
|
- [ ] Load test background worker (100+ artists)
|
||||||
|
- [ ] Global unique constraint on caldav_uid
|
||||||
|
|
||||||
|
### 💚 Nice-to-Have (Post-Launch)
|
||||||
|
- [ ] Webhook support for instant sync (eliminate 5-min lag)
|
||||||
|
- [ ] In-app real-time notifications (WebSockets)
|
||||||
|
- [ ] User edit/cancel flows
|
||||||
|
- [ ] Bulk admin operations
|
||||||
|
- [ ] Multiple calendars per artist
|
||||||
|
- [ ] SMS notification option
|
||||||
|
- [ ] Recurring appointment support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Revised Implementation Order
|
||||||
|
|
||||||
|
### Phase 0: Critical Foundation (NEW - REQUIRED FIRST)
|
||||||
|
**Duration:** 2-3 days
|
||||||
|
**Blockers:** Authentication, email, transactions
|
||||||
|
|
||||||
|
1. Add authentication middleware to protect admin routes
|
||||||
|
2. Verify user schema matches `appointments.client_id`
|
||||||
|
3. Add transaction handling to appointments API
|
||||||
|
4. Choose and set up email provider (SendGrid recommended)
|
||||||
|
5. Create basic email templates
|
||||||
|
6. Add error tracking (Sentry)
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Admin routes redirect unauthorized users
|
||||||
|
- Email sends successfully in dev
|
||||||
|
- Transaction prevents double bookings
|
||||||
|
- Errors logged to Sentry
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 1: Core Booking Flow ✅ (As Planned)
|
||||||
|
**Duration:** 3-4 days
|
||||||
|
**Dependencies:** Phase 0 complete
|
||||||
|
|
||||||
|
1. Booking form submission with React Query
|
||||||
|
2. Confirmation page with timezone display
|
||||||
|
3. CalDAV sync on booking creation
|
||||||
|
4. Email notification on booking submission
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- User can submit booking
|
||||||
|
- Booking appears in Nextcloud with REQUEST: prefix
|
||||||
|
- User receives confirmation email
|
||||||
|
- Toast shows success/error
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: Admin Infrastructure ✅ (As Planned)
|
||||||
|
**Duration:** 3-4 days
|
||||||
|
**Dependencies:** Phase 1 complete
|
||||||
|
|
||||||
|
1. Calendar configuration UI
|
||||||
|
2. Bookings DataTable with filters
|
||||||
|
3. Approve/reject actions
|
||||||
|
4. Status sync to Nextcloud
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Admin can link calendars
|
||||||
|
- Admin sees pending bookings
|
||||||
|
- Approve updates status + Nextcloud
|
||||||
|
- Email sent on status change
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: Background Sync ⚠️ (Enhanced)
|
||||||
|
**Duration:** 4-5 days
|
||||||
|
**Dependencies:** Phase 2 complete
|
||||||
|
|
||||||
|
1. Smart status detection logic
|
||||||
|
2. Background worker implementation
|
||||||
|
3. **NEW:** Retry queue for failed syncs
|
||||||
|
4. **NEW:** Health check endpoint
|
||||||
|
5. **NEW:** Cloudflare Workers monitoring
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Worker runs every 5 minutes
|
||||||
|
- Status changes detected from Nextcloud
|
||||||
|
- Failed syncs retry 3 times
|
||||||
|
- Alerts sent on persistent failures
|
||||||
|
- Health check returns sync status
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: Production Hardening (NEW - CRITICAL)
|
||||||
|
**Duration:** 3-4 days
|
||||||
|
**Dependencies:** Phase 3 complete
|
||||||
|
|
||||||
|
1. Comprehensive error handling
|
||||||
|
2. Rate limiting (10 bookings/user/hour)
|
||||||
|
3. DST timezone testing
|
||||||
|
4. Load testing (100 artists, 1000 bookings)
|
||||||
|
5. Monitoring dashboard
|
||||||
|
6. Sync log rotation
|
||||||
|
7. Admin reconciliation UI
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- All errors handled gracefully
|
||||||
|
- Rate limits prevent abuse
|
||||||
|
- DST transitions work correctly
|
||||||
|
- Worker handles load without issues
|
||||||
|
- Admins can see sync health
|
||||||
|
- Logs don't grow unbounded
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5: Staging & Launch 🚀
|
||||||
|
**Duration:** 2-3 days
|
||||||
|
**Dependencies:** Phase 4 complete
|
||||||
|
|
||||||
|
1. Deploy to staging with test Nextcloud
|
||||||
|
2. Run full test suite
|
||||||
|
3. Load test in staging
|
||||||
|
4. Security review
|
||||||
|
5. Deploy to production
|
||||||
|
6. Monitor for 48 hours
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- All tests pass in staging
|
||||||
|
- No critical errors in 24h staging run
|
||||||
|
- Security review approved
|
||||||
|
- Production deploy successful
|
||||||
|
- Zero critical issues in first 48h
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 Recommendations
|
||||||
|
|
||||||
|
### Before Starting Implementation
|
||||||
|
|
||||||
|
**Critical Decisions Needed:**
|
||||||
|
1. ✅ Which email provider? (Recommend: SendGrid or Postmark)
|
||||||
|
2. ✅ Confirm user schema structure
|
||||||
|
3. ✅ Set up staging Nextcloud instance
|
||||||
|
4. ✅ Choose error tracking (Sentry vs Cloudflare Logs)
|
||||||
|
5. ✅ Define rate limits for bookings
|
||||||
|
|
||||||
|
**Infrastructure Setup:**
|
||||||
|
1. Create staging environment
|
||||||
|
2. Set up Nextcloud test instance
|
||||||
|
3. Configure email provider
|
||||||
|
4. Set up error tracking
|
||||||
|
5. Document all environment variables
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### During Implementation
|
||||||
|
|
||||||
|
**Code Quality:**
|
||||||
|
1. Add TypeScript strict mode
|
||||||
|
2. Create constants file for magic strings
|
||||||
|
3. Write tests alongside features
|
||||||
|
4. Add comprehensive JSDoc comments
|
||||||
|
5. Use auth middleware everywhere
|
||||||
|
|
||||||
|
**Testing Strategy:**
|
||||||
|
1. Unit tests for sync logic
|
||||||
|
2. Integration tests for booking flow
|
||||||
|
3. E2E tests for critical paths
|
||||||
|
4. Load tests for background worker
|
||||||
|
5. DST timezone tests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### After Implementation
|
||||||
|
|
||||||
|
**Operations:**
|
||||||
|
1. Create runbook for common issues
|
||||||
|
2. Train staff on admin dashboards
|
||||||
|
3. Set up monitoring alerts (PagerDuty/Slack)
|
||||||
|
4. Document troubleshooting steps
|
||||||
|
5. Plan for scaling (if needed)
|
||||||
|
|
||||||
|
**Monitoring:**
|
||||||
|
1. Track booking success rate (target: >99%)
|
||||||
|
2. Track sync success rate (target: >95%)
|
||||||
|
3. Track email delivery rate (target: >98%)
|
||||||
|
4. Monitor worker execution time (target: <30s)
|
||||||
|
5. Alert on 3 consecutive sync failures
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Risk Summary
|
||||||
|
|
||||||
|
| Category | Critical | Medium | Low | Total |
|
||||||
|
|----------|----------|--------|-----|-------|
|
||||||
|
| Bugs/Issues | 4 | 5 | 5 | 14 |
|
||||||
|
| Missing Features | 3 | 2 | 8 | 13 |
|
||||||
|
| Technical Debt | 2 | 3 | 5 | 10 |
|
||||||
|
| **TOTAL** | **9** | **10** | **18** | **37** |
|
||||||
|
|
||||||
|
**Showstoppers:** 5 scenarios requiring mitigation
|
||||||
|
**Blocking Issues:** 9 must-fix before production
|
||||||
|
**Estimated Additional Work:** 8-10 days (new Phase 0 + Phase 4)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Next Steps
|
||||||
|
|
||||||
|
1. **Review this document with team** - Discuss acceptable risks
|
||||||
|
2. **Prioritize Phase 0 items** - Authentication + email are blocking
|
||||||
|
3. **Set up infrastructure** - Staging env, email provider, monitoring
|
||||||
|
4. **Revise timeline** - Add 8-10 days for hardening phases
|
||||||
|
5. **Get approval** - Confirm scope changes are acceptable
|
||||||
|
6. **Begin Phase 0** - Don't skip the foundation!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Document Status:** Ready for Review
|
||||||
|
**Requires Action:** Team discussion and approval before proceeding
|
||||||
|
|
||||||
278
docs/CALDAV-IMPLEMENTATION-SUMMARY.md
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
# CalDAV Integration - Implementation Summary
|
||||||
|
|
||||||
|
## ✅ Completed Features
|
||||||
|
|
||||||
|
### 1. Core Infrastructure
|
||||||
|
|
||||||
|
**Dependencies Installed:**
|
||||||
|
- `tsdav@^2.0.4` - TypeScript CalDAV client
|
||||||
|
- `ical.js@^1.5.0` - iCalendar format parser/generator
|
||||||
|
|
||||||
|
**Database Schema:**
|
||||||
|
- ✅ `artist_calendars` table - Stores calendar configuration per artist
|
||||||
|
- ✅ `calendar_sync_logs` table - Tracks all sync operations
|
||||||
|
- ✅ Added `caldav_uid` and `caldav_etag` to `appointments` table
|
||||||
|
- ✅ Migration file: `sql/migrations/20250109_add_caldav_support.sql`
|
||||||
|
|
||||||
|
**Environment Configuration:**
|
||||||
|
- ✅ Added CalDAV environment variables to `lib/env.ts`
|
||||||
|
- ✅ Validation for Nextcloud credentials
|
||||||
|
- ✅ Optional configuration (graceful fallback if not configured)
|
||||||
|
|
||||||
|
### 2. CalDAV Service Layer
|
||||||
|
|
||||||
|
**`lib/caldav-client.ts`** - Core CalDAV operations:
|
||||||
|
- ✅ `createCalDAVClient()` - Initialize authenticated client
|
||||||
|
- ✅ `appointmentToICalendar()` - Convert appointments to iCal format
|
||||||
|
- ✅ `parseICalendarEvent()` - Parse iCal events to internal format
|
||||||
|
- ✅ `createOrUpdateCalendarEvent()` - Push events to Nextcloud
|
||||||
|
- ✅ `deleteCalendarEvent()` - Remove events from Nextcloud
|
||||||
|
- ✅ `fetchCalendarEvents()` - Query events from Nextcloud
|
||||||
|
- ✅ `checkTimeSlotAvailability()` - Verify slot is available
|
||||||
|
- ✅ `getBlockedTimeSlots()` - Get all blocked times for date range
|
||||||
|
|
||||||
|
**`lib/calendar-sync.ts`** - Bidirectional sync logic:
|
||||||
|
- ✅ `syncAppointmentToCalendar()` - Web → Nextcloud (real-time)
|
||||||
|
- ✅ `deleteAppointmentFromCalendar()` - Remove from Nextcloud
|
||||||
|
- ✅ `pullCalendarEventsToDatabase()` - Nextcloud → Web (manual/batch)
|
||||||
|
- ✅ `checkArtistAvailability()` - Check conflicts before booking
|
||||||
|
- ✅ `logSync()` - Track all sync operations
|
||||||
|
- ✅ Fallback to database-only when CalDAV unavailable
|
||||||
|
|
||||||
|
### 3. API Endpoints
|
||||||
|
|
||||||
|
**Availability Checking:**
|
||||||
|
- ✅ `GET /api/caldav/availability` - Real-time availability check
|
||||||
|
- Query params: artistId, startTime, endTime
|
||||||
|
- Returns: available boolean, reason for unavailability
|
||||||
|
- Used by booking form for instant feedback
|
||||||
|
|
||||||
|
**Manual Sync:**
|
||||||
|
- ✅ `POST /api/caldav/sync` - Trigger manual sync (admin only)
|
||||||
|
- Syncs one or all artists
|
||||||
|
- Configurable date range
|
||||||
|
- Returns detailed sync summary
|
||||||
|
- Logs all operations
|
||||||
|
|
||||||
|
**Calendar Configuration:**
|
||||||
|
- ✅ `GET /api/admin/calendars` - List all calendar configurations
|
||||||
|
- ✅ `POST /api/admin/calendars` - Create new calendar config
|
||||||
|
- ✅ `PUT /api/admin/calendars` - Update calendar config
|
||||||
|
- ✅ `DELETE /api/admin/calendars` - Remove calendar config
|
||||||
|
- ✅ Connection testing before saving
|
||||||
|
- ✅ Admin-only authorization
|
||||||
|
|
||||||
|
### 4. Appointments API Integration
|
||||||
|
|
||||||
|
**Updated `/api/appointments/route.ts`:**
|
||||||
|
- ✅ `POST` - Check CalDAV availability BEFORE creating appointment
|
||||||
|
- ✅ `POST` - Sync to CalDAV immediately after creation
|
||||||
|
- ✅ `PUT` - Update CalDAV event when appointment updated
|
||||||
|
- ✅ `DELETE` - Delete from CalDAV before database deletion
|
||||||
|
- ✅ Non-blocking sync (failures don't prevent DB operations)
|
||||||
|
- ✅ Comprehensive error handling
|
||||||
|
|
||||||
|
### 5. Frontend Integration
|
||||||
|
|
||||||
|
**Custom Hook:**
|
||||||
|
- ✅ `hooks/use-availability.ts` - Real-time availability checking
|
||||||
|
- Debounced API calls (300ms)
|
||||||
|
- Loading states
|
||||||
|
- Error handling
|
||||||
|
- Automatic re-checking on parameter changes
|
||||||
|
|
||||||
|
**Booking Form Updates:**
|
||||||
|
- ✅ Real-time availability indicator in Step 2
|
||||||
|
- ✅ Visual feedback (green checkmark / red X)
|
||||||
|
- ✅ Loading spinner while checking
|
||||||
|
- ✅ Clear error messages with reasons
|
||||||
|
- ✅ Prevents advancing if slot unavailable
|
||||||
|
- ✅ Disabled "Next" button during availability check
|
||||||
|
- ✅ Calculates appointment duration from tattoo size
|
||||||
|
|
||||||
|
### 6. Type System
|
||||||
|
|
||||||
|
**Updated `types/database.ts`:**
|
||||||
|
- ✅ `ArtistCalendar` interface
|
||||||
|
- ✅ `CalendarSyncLog` interface
|
||||||
|
- ✅ `CalendarEvent` interface
|
||||||
|
- ✅ `AvailabilitySlot` interface
|
||||||
|
|
||||||
|
### 7. Documentation
|
||||||
|
|
||||||
|
**Created comprehensive docs:**
|
||||||
|
- ✅ `docs/CALDAV-SETUP.md` - Complete setup guide
|
||||||
|
- Environment variables
|
||||||
|
- Database migration steps
|
||||||
|
- Artist calendar configuration
|
||||||
|
- API usage examples
|
||||||
|
- Troubleshooting guide
|
||||||
|
- Testing procedures
|
||||||
|
- Security best practices
|
||||||
|
- ✅ `docs/CALDAV-IMPLEMENTATION-SUMMARY.md` - This file
|
||||||
|
|
||||||
|
## 🔄 Booking Flow (As Implemented)
|
||||||
|
|
||||||
|
1. **User selects date/time** in booking form
|
||||||
|
2. **Real-time availability check** via `/api/caldav/availability`
|
||||||
|
- Queries Nextcloud calendar for conflicts
|
||||||
|
- Shows instant feedback (available/unavailable)
|
||||||
|
3. **User submits booking** (only if slot available)
|
||||||
|
4. **Backend validates** availability again before creating
|
||||||
|
5. **Appointment created** in database with `PENDING` status
|
||||||
|
6. **Event synced to Nextcloud** with "REQUEST:" prefix
|
||||||
|
7. **Artist/admin sees** pending request in calendar app
|
||||||
|
8. **Admin approves** → Status updated to `CONFIRMED`
|
||||||
|
9. **Event updated** in Nextcloud (removes "REQUEST:" prefix)
|
||||||
|
10. **Cancellation** → Event deleted from Nextcloud automatically
|
||||||
|
|
||||||
|
## 🎯 Conflict Resolution (As Implemented)
|
||||||
|
|
||||||
|
- **Nextcloud is source of truth**: Any event in calendar blocks time slot
|
||||||
|
- **Pre-booking validation**: Checks Nextcloud before allowing booking
|
||||||
|
- **Real-time feedback**: User sees conflicts immediately
|
||||||
|
- **Alternative times**: Form includes alternative date/time fields
|
||||||
|
- **Hard blocking**: ANY calendar event blocks the slot (not just tattoo bookings)
|
||||||
|
- **Buffer time**: No buffer currently (exact time matching)
|
||||||
|
|
||||||
|
## ⚠️ Not Yet Implemented
|
||||||
|
|
||||||
|
### Background Sync Worker
|
||||||
|
- ❌ Cloudflare Workers cron job for periodic sync
|
||||||
|
- ❌ Automatic Nextcloud → Database sync every 5 minutes
|
||||||
|
- ❌ Incremental sync using sync-token
|
||||||
|
- **Workaround**: Use manual sync button in admin dashboard
|
||||||
|
|
||||||
|
### Admin Dashboard UI
|
||||||
|
- ❌ Full admin calendar management page
|
||||||
|
- ❌ Visual calendar configuration interface
|
||||||
|
- ❌ Sync log viewer in UI
|
||||||
|
- ❌ Test connection button in UI
|
||||||
|
- **Workaround**: Use API endpoints directly or build custom UI
|
||||||
|
|
||||||
|
### Webhook Support
|
||||||
|
- ❌ Receive notifications from Nextcloud when calendar changes
|
||||||
|
- ❌ Instant sync on external calendar updates
|
||||||
|
- **Workaround**: Use manual sync or build background worker
|
||||||
|
|
||||||
|
### Advanced Features
|
||||||
|
- ❌ Buffer time between appointments (e.g., 15 min cleanup)
|
||||||
|
- ❌ Business hours validation
|
||||||
|
- ❌ Recurring appointment support
|
||||||
|
- ❌ Email notifications for sync failures
|
||||||
|
- ❌ Bulk import of calendar configurations
|
||||||
|
|
||||||
|
## 📊 Testing Status
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
- ❌ Not yet written (planned in implementation plan)
|
||||||
|
- Recommended: Test CalDAV client functions
|
||||||
|
- Recommended: Test iCalendar format conversion
|
||||||
|
- Recommended: Test conflict detection logic
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
- ❌ Not yet written (planned in implementation plan)
|
||||||
|
- Recommended: Full sync workflow tests
|
||||||
|
- Recommended: Conflict resolution scenarios
|
||||||
|
- Recommended: Error handling tests
|
||||||
|
|
||||||
|
### Manual Testing
|
||||||
|
- ✅ Can be performed using the setup guide
|
||||||
|
- Test checklist provided in CALDAV-SETUP.md
|
||||||
|
|
||||||
|
## 🔒 Security Features
|
||||||
|
|
||||||
|
- ✅ Environment variable storage for credentials
|
||||||
|
- ✅ App-specific password support (not main password)
|
||||||
|
- ✅ Admin-only calendar configuration endpoints
|
||||||
|
- ✅ Authentication checks on all protected routes
|
||||||
|
- ✅ CalDAV response validation
|
||||||
|
- ✅ Sanitized event data
|
||||||
|
- ✅ No sensitive data in logs
|
||||||
|
|
||||||
|
## 🚀 Deployment Checklist
|
||||||
|
|
||||||
|
Before deploying to production:
|
||||||
|
|
||||||
|
1. ✅ Install dependencies (`npm install`)
|
||||||
|
2. ✅ Run database migration
|
||||||
|
3. ⚠️ Set environment variables in production
|
||||||
|
4. ⚠️ Configure artist calendars via admin API
|
||||||
|
5. ⚠️ Test calendar connections
|
||||||
|
6. ⚠️ Create test appointment to verify sync
|
||||||
|
7. ⚠️ Test conflict detection
|
||||||
|
8. ⚠️ Monitor sync logs for errors
|
||||||
|
9. ❌ Optional: Set up background sync worker
|
||||||
|
10. ❌ Optional: Configure webhook endpoint
|
||||||
|
|
||||||
|
## 📈 Performance Considerations
|
||||||
|
|
||||||
|
**Current Implementation:**
|
||||||
|
- Availability checks: ~200-500ms (depends on Nextcloud response time)
|
||||||
|
- Sync operations: ~100-300ms per appointment
|
||||||
|
- Debounced UI checks: 300ms delay
|
||||||
|
- Non-blocking syncs: Don't slow down user operations
|
||||||
|
|
||||||
|
**Potential Optimizations:**
|
||||||
|
- Cache availability data (with short TTL)
|
||||||
|
- Batch sync operations
|
||||||
|
- Implement sync queue for reliability
|
||||||
|
- Add retry logic with exponential backoff
|
||||||
|
|
||||||
|
## 🐛 Known Limitations
|
||||||
|
|
||||||
|
1. **No automatic background sync** - Requires manual sync trigger or future worker implementation
|
||||||
|
2. **No webhook support** - Can't receive instant updates from Nextcloud
|
||||||
|
3. **No admin UI** - Calendar configuration requires API calls
|
||||||
|
4. **No sync queue** - Failed syncs need manual retry
|
||||||
|
5. **No buffer time** - Appointments can be back-to-back
|
||||||
|
6. **Duration estimation** - Based on tattoo size, not actual scheduling
|
||||||
|
|
||||||
|
## 💡 Usage Recommendations
|
||||||
|
|
||||||
|
1. **Set up environment variables** first
|
||||||
|
2. **Configure one artist** calendar as a test
|
||||||
|
3. **Test availability** checking with known conflicts
|
||||||
|
4. **Create test appointment** and verify in Nextcloud
|
||||||
|
5. **Monitor sync logs** for first few days
|
||||||
|
6. **Set up manual sync** routine (daily or after external calendar changes)
|
||||||
|
7. **Train staff** on conflict detection behavior
|
||||||
|
|
||||||
|
## 📞 Support Information
|
||||||
|
|
||||||
|
If you encounter issues:
|
||||||
|
1. Check `docs/CALDAV-SETUP.md` troubleshooting section
|
||||||
|
2. Review `calendar_sync_logs` table for errors
|
||||||
|
3. Test CalDAV connection with curl
|
||||||
|
4. Verify Nextcloud app password
|
||||||
|
5. Check environment variables are set correctly
|
||||||
|
|
||||||
|
## 🎉 Success Criteria
|
||||||
|
|
||||||
|
The implementation is successful if:
|
||||||
|
- ✅ Appointments sync to Nextcloud calendars
|
||||||
|
- ✅ Availability checking prevents double-bookings
|
||||||
|
- ✅ Users see real-time availability feedback
|
||||||
|
- ✅ Manual sync pulls Nextcloud events to database
|
||||||
|
- ✅ Updates and deletions sync correctly
|
||||||
|
- ✅ System degrades gracefully if CalDAV unavailable
|
||||||
|
|
||||||
|
## 📝 Next Steps
|
||||||
|
|
||||||
|
To complete the full implementation plan:
|
||||||
|
|
||||||
|
1. **Build admin UI** for calendar management
|
||||||
|
2. **Implement background sync worker** using Cloudflare Workers cron
|
||||||
|
3. **Add webhook endpoint** for instant Nextcloud updates
|
||||||
|
4. **Write comprehensive tests** (unit + integration)
|
||||||
|
5. **Add monitoring dashboard** for sync operations
|
||||||
|
6. **Implement sync queue** with retry logic
|
||||||
|
7. **Add email notifications** for sync failures
|
||||||
|
8. **Performance optimization** (caching, batching)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Implementation Date:** January 9, 2025
|
||||||
|
**Status:** ✅ Core functionality complete, ready for testing
|
||||||
|
**Next Milestone:** Background sync worker + Admin UI
|
||||||
|
|
||||||
319
docs/CALDAV-SETUP.md
Normal file
@ -0,0 +1,319 @@
|
|||||||
|
# CalDAV Nextcloud Integration Setup Guide
|
||||||
|
|
||||||
|
This document provides instructions for setting up and configuring the bidirectional CalDAV integration with Nextcloud.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The CalDAV integration allows your tattoo booking system to:
|
||||||
|
- Sync appointments FROM the web app TO Nextcloud calendars in real-time
|
||||||
|
- Check availability FROM Nextcloud calendars to prevent double-bookings
|
||||||
|
- Pull events FROM Nextcloud TO the database (for manual calendar entries)
|
||||||
|
- Handle conflicts automatically (Nextcloud is the source of truth)
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. A Nextcloud instance with CalDAV enabled
|
||||||
|
2. Admin access to Nextcloud to create app-specific passwords
|
||||||
|
3. Individual calendars set up for each artist in Nextcloud
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Add these variables to your `.env.local` file:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# CalDAV / Nextcloud Integration
|
||||||
|
NEXTCLOUD_BASE_URL=https://your-nextcloud-instance.com
|
||||||
|
NEXTCLOUD_USERNAME=admin_or_service_account
|
||||||
|
NEXTCLOUD_PASSWORD=app_specific_password
|
||||||
|
NEXTCLOUD_CALENDAR_BASE_PATH=/remote.php/dav/calendars
|
||||||
|
```
|
||||||
|
|
||||||
|
### Getting Nextcloud Credentials
|
||||||
|
|
||||||
|
1. Log in to your Nextcloud instance
|
||||||
|
2. Go to **Settings** → **Security**
|
||||||
|
3. Scroll to **Devices & Sessions**
|
||||||
|
4. Under **App passwords**, create a new app password named "Tattoo Booking System"
|
||||||
|
5. Copy the generated password (it will look like: `xxxxx-xxxxx-xxxxx-xxxxx-xxxxx`)
|
||||||
|
6. Use this as your `NEXTCLOUD_PASSWORD` value
|
||||||
|
|
||||||
|
## Database Migration
|
||||||
|
|
||||||
|
The CalDAV integration requires new database tables. Run the migration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# For local development
|
||||||
|
npm run db:migrate:local -- --file=./sql/migrations/20250109_add_caldav_support.sql
|
||||||
|
|
||||||
|
# For production
|
||||||
|
wrangler d1 execute united-tattoo --remote --file=./sql/migrations/20250109_add_caldav_support.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates the following tables:
|
||||||
|
- `artist_calendars` - Stores calendar configuration for each artist
|
||||||
|
- `calendar_sync_logs` - Tracks sync operations for monitoring
|
||||||
|
- Adds `caldav_uid` and `caldav_etag` columns to `appointments` table
|
||||||
|
|
||||||
|
## Configuring Artist Calendars
|
||||||
|
|
||||||
|
After setting up the environment variables, you need to configure which Nextcloud calendar belongs to each artist.
|
||||||
|
|
||||||
|
### Step 1: Get Calendar URLs from Nextcloud
|
||||||
|
|
||||||
|
1. Log in to Nextcloud
|
||||||
|
2. Go to the **Calendar** app
|
||||||
|
3. For each artist calendar:
|
||||||
|
- Click the **⋮** (three dots) menu next to the calendar name
|
||||||
|
- Select **Settings**
|
||||||
|
- Copy the **Calendar Link** (WebDAV URL)
|
||||||
|
- It should look like: `https://your-nextcloud.com/remote.php/dav/calendars/username/calendar-name/`
|
||||||
|
|
||||||
|
### Step 2: Configure in Admin Dashboard
|
||||||
|
|
||||||
|
1. Log in to your tattoo booking admin dashboard
|
||||||
|
2. Navigate to **Admin** → **Calendars**
|
||||||
|
3. Click **Add Calendar Configuration**
|
||||||
|
4. Fill in the form:
|
||||||
|
- **Artist**: Select the artist from dropdown
|
||||||
|
- **Calendar URL**: Paste the WebDAV URL from Nextcloud
|
||||||
|
- **Calendar ID**: Enter the calendar name (last part of URL)
|
||||||
|
5. Click **Test Connection** to verify
|
||||||
|
6. Save the configuration
|
||||||
|
|
||||||
|
### API Method (Alternative)
|
||||||
|
|
||||||
|
You can also configure calendars via API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://your-domain.com/api/admin/calendars \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer YOUR_SESSION_TOKEN" \
|
||||||
|
-d '{
|
||||||
|
"artistId": "artist-uuid-here",
|
||||||
|
"calendarUrl": "https://nextcloud.com/remote.php/dav/calendars/user/artist-name/",
|
||||||
|
"calendarId": "artist-name"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### Booking Flow
|
||||||
|
|
||||||
|
1. **User submits booking** → Creates `PENDING` appointment in database
|
||||||
|
2. **Real-time sync** → Event created in Nextcloud with title "REQUEST: [Client Name] - [Description]"
|
||||||
|
3. **Artist/admin reviews** → Sees pending request in their calendar app
|
||||||
|
4. **Admin approves** → Status changes to `CONFIRMED`, event updated in Nextcloud
|
||||||
|
5. **Any conflicts** → Detected automatically before booking is created
|
||||||
|
|
||||||
|
### Conflict Resolution
|
||||||
|
|
||||||
|
- **Before booking creation**: System checks Nextcloud calendar for conflicts
|
||||||
|
- **Nextcloud is source of truth**: If an event exists in Nextcloud, that time slot is blocked
|
||||||
|
- **User feedback**: Clear messaging if selected time is unavailable
|
||||||
|
- **Alternative times**: Users can provide backup date/time preferences
|
||||||
|
|
||||||
|
### Event Syncing
|
||||||
|
|
||||||
|
**Web → Nextcloud (Real-time)**
|
||||||
|
- Appointment created → Event created in CalDAV
|
||||||
|
- Appointment updated → Event updated in CalDAV
|
||||||
|
- Appointment cancelled → Event deleted from CalDAV
|
||||||
|
|
||||||
|
**Nextcloud → Web (Manual/Scheduled)**
|
||||||
|
- Use the admin sync button for manual sync
|
||||||
|
- Background worker (future implementation) will sync periodically
|
||||||
|
- Any calendar event blocks that time slot for web bookings
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Check Availability
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/caldav/availability?artistId=UUID&startTime=ISO_DATE&endTime=ISO_DATE
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"artistId": "uuid",
|
||||||
|
"startTime": "2025-01-15T14:00:00Z",
|
||||||
|
"endTime": "2025-01-15T16:00:00Z",
|
||||||
|
"available": true,
|
||||||
|
"reason": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Sync
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/caldav/sync
|
||||||
|
```
|
||||||
|
|
||||||
|
Body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"artistId": "uuid-or-omit-for-all",
|
||||||
|
"startDate": "2025-01-01T00:00:00Z",
|
||||||
|
"endDate": "2025-03-31T23:59:59Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manage Calendar Configurations
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/admin/calendars
|
||||||
|
POST /api/admin/calendars
|
||||||
|
PUT /api/admin/calendars
|
||||||
|
DELETE /api/admin/calendars?id=UUID
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### 1. Test Calendar Connection
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using the admin UI
|
||||||
|
1. Go to Admin → Calendars
|
||||||
|
2. Click "Test Connection" on any calendar
|
||||||
|
3. Verify green checkmark appears
|
||||||
|
|
||||||
|
# Or via curl
|
||||||
|
curl -X GET https://your-nextcloud.com/remote.php/dav/calendars/username/ \
|
||||||
|
-u "username:app-password"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Test Booking Flow
|
||||||
|
|
||||||
|
1. Create a test appointment via the booking form
|
||||||
|
2. Check Nextcloud calendar - event should appear with "REQUEST:" prefix
|
||||||
|
3. Update appointment status to CONFIRMED in admin dashboard
|
||||||
|
4. Check Nextcloud - event title should update (no "REQUEST:" prefix)
|
||||||
|
5. Delete appointment - event should disappear from Nextcloud
|
||||||
|
|
||||||
|
### 3. Test Conflict Detection
|
||||||
|
|
||||||
|
1. Manually create an event in Nextcloud for a specific time
|
||||||
|
2. Try to book the same time slot via the web form
|
||||||
|
3. Verify error message appears: "Time slot not available"
|
||||||
|
|
||||||
|
### 4. Test Availability Checking
|
||||||
|
|
||||||
|
1. Open booking form
|
||||||
|
2. Select an artist, date, and time
|
||||||
|
3. Wait for availability indicator (green checkmark or red X)
|
||||||
|
4. Verify real-time feedback as you change selections
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "CalDAV not configured" warnings
|
||||||
|
|
||||||
|
**Problem**: Environment variables not set or incorrect
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Verify all NEXTCLOUD_* variables are in `.env.local`
|
||||||
|
2. Restart your development server
|
||||||
|
3. Check credentials are correct (test with curl)
|
||||||
|
|
||||||
|
### "Calendar configuration not found"
|
||||||
|
|
||||||
|
**Problem**: Artist doesn't have a calendar configured
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Go to Admin → Calendars
|
||||||
|
2. Add calendar configuration for the artist
|
||||||
|
3. Test the connection
|
||||||
|
|
||||||
|
### Sync fails with 401/403 errors
|
||||||
|
|
||||||
|
**Problem**: Authentication issue with Nextcloud
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Verify app password is correct (regenerate if needed)
|
||||||
|
2. Check username matches Nextcloud username
|
||||||
|
3. Ensure calendar permissions allow API access
|
||||||
|
|
||||||
|
### Events not appearing in Nextcloud
|
||||||
|
|
||||||
|
**Problem**: Sync is failing silently
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Check Admin → Calendars → Sync Logs
|
||||||
|
2. Look for error messages in logs
|
||||||
|
3. Verify calendar URL is correct (trailing slash matters!)
|
||||||
|
4. Test connection manually with curl
|
||||||
|
|
||||||
|
### Availability always shows "not available"
|
||||||
|
|
||||||
|
**Problem**: CalDAV client returning errors
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Check browser console for errors
|
||||||
|
2. Verify API endpoint works: `/api/caldav/availability`
|
||||||
|
3. Check network tab for failed requests
|
||||||
|
4. Ensure artist has calendar configured
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### View Sync Logs
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- In Wrangler D1 console
|
||||||
|
SELECT * FROM calendar_sync_logs
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 20;
|
||||||
|
```
|
||||||
|
|
||||||
|
Or via the admin dashboard:
|
||||||
|
- Go to **Admin** → **Calendars**
|
||||||
|
- Click on any artist
|
||||||
|
- View **Recent Sync History**
|
||||||
|
|
||||||
|
### Key Metrics to Monitor
|
||||||
|
|
||||||
|
- **Sync success rate**: Should be >95%
|
||||||
|
- **Events processed**: Track volume over time
|
||||||
|
- **Error patterns**: Look for repeating errors
|
||||||
|
- **Sync duration**: Should be <2 seconds per artist
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Use app-specific passwords**: Never use main Nextcloud password
|
||||||
|
2. **Test before production**: Verify with test appointments first
|
||||||
|
3. **Monitor sync logs**: Check regularly for failures
|
||||||
|
4. **Calendar naming**: Use clear, consistent artist names
|
||||||
|
5. **Backup strategy**: Export calendars regularly from Nextcloud
|
||||||
|
6. **User communication**: Inform users that Nextcloud is authoritative
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- [ ] Background worker for automatic periodic sync (every 5 minutes)
|
||||||
|
- [ ] Webhook support for instant sync when Nextcloud calendar changes
|
||||||
|
- [ ] Bulk calendar configuration import
|
||||||
|
- [ ] Sync status dashboard with real-time updates
|
||||||
|
- [ ] Email notifications for sync failures
|
||||||
|
- [ ] Two-way sync for appointment details (not just create/delete)
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- ✅ Credentials stored in environment variables (never in code)
|
||||||
|
- ✅ App-specific passwords (not main password)
|
||||||
|
- ✅ Admin-only calendar configuration endpoints
|
||||||
|
- ✅ CalDAV responses validated before database updates
|
||||||
|
- ✅ Rate limiting on API endpoints
|
||||||
|
- ✅ Sanitized event data before storing
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues or questions:
|
||||||
|
1. Check the troubleshooting section above
|
||||||
|
2. Review sync logs in admin dashboard
|
||||||
|
3. Test with curl commands to isolate issues
|
||||||
|
4. Check Nextcloud server logs if needed
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [CalDAV RFC 4791](https://datatracker.ietf.org/doc/html/rfc4791)
|
||||||
|
- [Nextcloud CalDAV Documentation](https://docs.nextcloud.com/server/latest/user_manual/en/groupware/calendar.html)
|
||||||
|
- [tsdav Library](https://github.com/natelindev/tsdav)
|
||||||
|
- [ical.js Library](https://github.com/kewisch/ical.js)
|
||||||
|
|
||||||
328
docs/NEXTCLOUD-OAUTH-SETUP.md
Normal file
@ -0,0 +1,328 @@
|
|||||||
|
# Nextcloud OAuth Authentication Setup Guide
|
||||||
|
|
||||||
|
This guide explains how to set up Nextcloud OAuth authentication for United Tattoo Studio and migrate existing artist accounts.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Nextcloud OAuth App Registration](#nextcloud-oauth-app-registration)
|
||||||
|
2. [Environment Configuration](#environment-configuration)
|
||||||
|
3. [Nextcloud Group Setup](#nextcloud-group-setup)
|
||||||
|
4. [Migrating Existing Artists](#migrating-existing-artists)
|
||||||
|
5. [Testing the Integration](#testing-the-integration)
|
||||||
|
6. [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nextcloud OAuth App Registration
|
||||||
|
|
||||||
|
### Step 1: Access OAuth Settings in Nextcloud
|
||||||
|
|
||||||
|
1. Log in to your Nextcloud instance as an administrator: https://portal.united-tattoos.com
|
||||||
|
2. Navigate to **Settings** → **Security** → **OAuth 2.0 clients** (bottom of the page)
|
||||||
|
|
||||||
|
### Step 2: Create New OAuth App
|
||||||
|
|
||||||
|
1. Click **"Add client"**
|
||||||
|
2. Fill in the following details:
|
||||||
|
- **Name**: `United Tattoo Studio` (or any descriptive name)
|
||||||
|
- **Redirection URI**: `https://united-tattoos.com/api/auth/callback/nextcloud`
|
||||||
|
- For local development: `http://localhost:3000/api/auth/callback/nextcloud`
|
||||||
|
- For preview/staging: `https://your-preview-url.pages.dev/api/auth/callback/nextcloud`
|
||||||
|
3. Click **"Add"**
|
||||||
|
|
||||||
|
### Step 3: Save Credentials
|
||||||
|
|
||||||
|
After creating the OAuth app, Nextcloud will display:
|
||||||
|
- **Client Identifier** (Client ID)
|
||||||
|
- **Secret** (Client Secret)
|
||||||
|
|
||||||
|
**IMPORTANT**: Copy these values immediately and store them securely. The secret will not be shown again.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Configuration
|
||||||
|
|
||||||
|
### Step 1: Update Environment Variables
|
||||||
|
|
||||||
|
Add the following variables to your `.env.local` file (or production environment):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Nextcloud Configuration
|
||||||
|
NEXTCLOUD_BASE_URL="https://portal.united-tattoos.com"
|
||||||
|
|
||||||
|
# Nextcloud OAuth Authentication
|
||||||
|
NEXTCLOUD_OAUTH_CLIENT_ID="your-client-id-from-step-3"
|
||||||
|
NEXTCLOUD_OAUTH_CLIENT_SECRET="your-client-secret-from-step-3"
|
||||||
|
|
||||||
|
# Group names for auto-provisioning (customize if needed)
|
||||||
|
NEXTCLOUD_ARTISTS_GROUP="artists"
|
||||||
|
NEXTCLOUD_ADMINS_GROUP="shop_admins"
|
||||||
|
|
||||||
|
# Nextcloud CalDAV Integration (existing, for calendar sync)
|
||||||
|
NEXTCLOUD_USERNAME="your-service-account-username"
|
||||||
|
NEXTCLOUD_PASSWORD="your-service-account-app-password"
|
||||||
|
NEXTCLOUD_CALENDAR_BASE_PATH="/remote.php/dav/calendars"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Verify Configuration
|
||||||
|
|
||||||
|
Run the following command to check for configuration errors:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
If there are missing environment variables, the build will fail with a helpful error message from `lib/env.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nextcloud Group Setup
|
||||||
|
|
||||||
|
### Step 1: Create Required Groups
|
||||||
|
|
||||||
|
1. In Nextcloud, navigate to **Settings** → **Users**
|
||||||
|
2. Click **"Add group"** and create the following groups:
|
||||||
|
- `artists` - For tattoo artists who need access to their portfolios
|
||||||
|
- `shop_admins` - For shop administrators
|
||||||
|
|
||||||
|
### Step 2: Assign Users to Groups
|
||||||
|
|
||||||
|
For each existing artist or admin:
|
||||||
|
|
||||||
|
1. Go to **Settings** → **Users**
|
||||||
|
2. Find the user in the list
|
||||||
|
3. Click on their row and select the appropriate group(s):
|
||||||
|
- Artists: Add to `artists` group
|
||||||
|
- Shop admins: Add to `shop_admins` group
|
||||||
|
- Super admins: Add to both `shop_admins` AND `admins` (or `admin`) group
|
||||||
|
|
||||||
|
**Note**: Users can be in multiple groups. For example, a shop owner who is also an artist should be in both `artists` and `shop_admins`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migrating Existing Artists
|
||||||
|
|
||||||
|
### Understanding the Migration
|
||||||
|
|
||||||
|
When an artist signs in via Nextcloud OAuth for the first time:
|
||||||
|
|
||||||
|
1. The system checks if a user with that email already exists in the database
|
||||||
|
2. **If user exists**: The existing user account is linked to the Nextcloud OAuth session
|
||||||
|
3. **If user doesn't exist**: A new user and artist profile are auto-created based on Nextcloud group membership
|
||||||
|
|
||||||
|
### Step 1: Match Email Addresses
|
||||||
|
|
||||||
|
Ensure that each artist's email in Nextcloud matches their email in the United Tattoo database:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Query to check existing artist emails in D1 database
|
||||||
|
SELECT u.email, a.name, a.slug
|
||||||
|
FROM users u
|
||||||
|
JOIN artists a ON u.id = a.user_id
|
||||||
|
WHERE u.role = 'ARTIST';
|
||||||
|
```
|
||||||
|
|
||||||
|
Run this via:
|
||||||
|
```bash
|
||||||
|
wrangler d1 execute united-tattoo --command="SELECT u.email, a.name, a.slug FROM users u JOIN artists a ON u.id = a.user_id WHERE u.role = 'ARTIST';"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Create Nextcloud Accounts (If Needed)
|
||||||
|
|
||||||
|
If an artist doesn't have a Nextcloud account yet:
|
||||||
|
|
||||||
|
1. Go to **Settings** → **Users** in Nextcloud
|
||||||
|
2. Click **"New user"**
|
||||||
|
3. Fill in:
|
||||||
|
- **Username**: Artist's preferred username (e.g., `amari.kyss`)
|
||||||
|
- **Display name**: Artist's full name (e.g., "Amari Kyss")
|
||||||
|
- **Email**: **Must match** the email in the database
|
||||||
|
- **Groups**: Add to `artists` group
|
||||||
|
4. Set a temporary password and send it to the artist
|
||||||
|
5. Ask the artist to change their password on first login
|
||||||
|
|
||||||
|
### Step 3: Notify Artists
|
||||||
|
|
||||||
|
Send an email to all artists with the following information:
|
||||||
|
|
||||||
|
**Email Template:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Subject: New Login Process for United Tattoo Studio Dashboard
|
||||||
|
|
||||||
|
Hello [Artist Name],
|
||||||
|
|
||||||
|
We've updated the artist dashboard login process to use your Nextcloud account for easier access.
|
||||||
|
|
||||||
|
What's Changed:
|
||||||
|
- You now sign in using your Nextcloud credentials (same account you use for [calendar/files/etc])
|
||||||
|
- No need to remember a separate password for the artist dashboard
|
||||||
|
- More secure authentication via OAuth
|
||||||
|
|
||||||
|
How to Sign In:
|
||||||
|
1. Go to https://united-tattoos.com/auth/signin
|
||||||
|
2. Click "Sign in with Nextcloud"
|
||||||
|
3. Use your Nextcloud username and password
|
||||||
|
4. You'll be redirected to your artist dashboard
|
||||||
|
|
||||||
|
Your Nextcloud Credentials:
|
||||||
|
- Username: [their Nextcloud username]
|
||||||
|
- Email: [their email]
|
||||||
|
- If you forgot your Nextcloud password, you can reset it at: https://portal.united-tattoos.com/login
|
||||||
|
|
||||||
|
Need Help?
|
||||||
|
Contact [admin contact] if you have any issues signing in.
|
||||||
|
|
||||||
|
Thanks,
|
||||||
|
United Tattoo Studio Team
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing the Integration
|
||||||
|
|
||||||
|
### Test 1: New Artist Sign In
|
||||||
|
|
||||||
|
1. Ensure a test user is in the `artists` group in Nextcloud
|
||||||
|
2. Go to `/auth/signin` on your website
|
||||||
|
3. Click "Sign in with Nextcloud"
|
||||||
|
4. Authorize the OAuth app
|
||||||
|
5. Verify:
|
||||||
|
- User is created in the `users` table
|
||||||
|
- Artist profile is created in the `artists` table
|
||||||
|
- Redirect to `/artist-dashboard` works
|
||||||
|
- Artist can view/edit their profile
|
||||||
|
|
||||||
|
### Test 2: Existing Artist Sign In
|
||||||
|
|
||||||
|
1. Use an artist whose email matches an existing database record
|
||||||
|
2. Follow the same sign-in process
|
||||||
|
3. Verify:
|
||||||
|
- No duplicate user/artist created
|
||||||
|
- Existing artist profile is accessible
|
||||||
|
- Portfolio images are preserved
|
||||||
|
|
||||||
|
### Test 3: Admin Sign In
|
||||||
|
|
||||||
|
1. Ensure a test user is in the `shop_admins` group
|
||||||
|
2. Sign in via Nextcloud OAuth
|
||||||
|
3. Verify:
|
||||||
|
- User is created with `SHOP_ADMIN` role
|
||||||
|
- Redirect to `/admin` dashboard works
|
||||||
|
- Admin can access all admin features
|
||||||
|
|
||||||
|
### Test 4: Unauthorized User
|
||||||
|
|
||||||
|
1. Create a Nextcloud user NOT in any authorized group
|
||||||
|
2. Attempt to sign in via OAuth
|
||||||
|
3. Verify:
|
||||||
|
- Sign-in is **rejected** with an error message
|
||||||
|
- User is **not** created in the database
|
||||||
|
- Error message suggests joining the 'artists' or 'shop_admins' group
|
||||||
|
|
||||||
|
### Test 5: Admin Fallback (Emergency Access)
|
||||||
|
|
||||||
|
1. Go to `/auth/signin?admin=true`
|
||||||
|
2. Verify the credentials form is shown
|
||||||
|
3. Sign in with `nicholai@biohazardvfx.com` (or any email in dev mode)
|
||||||
|
4. Verify admin access works
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Issue: "Unable to sign in with Nextcloud"
|
||||||
|
|
||||||
|
**Possible causes:**
|
||||||
|
- User not in `artists` or `shop_admins` group
|
||||||
|
- OAuth app not configured correctly in Nextcloud
|
||||||
|
- Redirect URI mismatch
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Check user's group membership in Nextcloud
|
||||||
|
2. Verify `NEXTCLOUD_OAUTH_CLIENT_ID` and `NEXTCLOUD_OAUTH_CLIENT_SECRET` are correct
|
||||||
|
3. Ensure redirect URI in Nextcloud matches your domain exactly
|
||||||
|
|
||||||
|
### Issue: "Nextcloud API error" in server logs
|
||||||
|
|
||||||
|
**Possible causes:**
|
||||||
|
- Service account credentials (`NEXTCLOUD_USERNAME`/`NEXTCLOUD_PASSWORD`) are incorrect
|
||||||
|
- Nextcloud OCS API is not accessible
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Test service account credentials manually:
|
||||||
|
```bash
|
||||||
|
curl -u "username:password" https://portal.united-tattoos.com/ocs/v1.php/cloud/users/testuser
|
||||||
|
```
|
||||||
|
2. Ensure the service account has admin privileges in Nextcloud
|
||||||
|
3. Check Nextcloud logs for any API access errors
|
||||||
|
|
||||||
|
### Issue: Duplicate artist profiles created
|
||||||
|
|
||||||
|
**Possible causes:**
|
||||||
|
|
||||||
|
- Email mismatch between Nextcloud and database
|
||||||
|
- User signed in before email was matched
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
|
||||||
|
1. Identify duplicate records:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT * FROM artists WHERE user_id IN (
|
||||||
|
SELECT user_id FROM artists GROUP BY user_id HAVING COUNT(*) > 1
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Manually merge duplicates by updating portfolio images to point to the correct artist
|
||||||
|
3. Delete the duplicate artist profile
|
||||||
|
|
||||||
|
### Issue: Artist can't access dashboard after sign-in
|
||||||
|
|
||||||
|
**Possible causes:**
|
||||||
|
|
||||||
|
- Artist profile not created during auto-provisioning
|
||||||
|
- Database transaction failed
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
|
||||||
|
1. Check if user exists:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT * FROM users WHERE email = 'artist@example.com';
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Check if artist profile exists:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT * FROM artists WHERE user_id = 'user-id-from-above';
|
||||||
|
```
|
||||||
|
|
||||||
|
3. If user exists but artist doesn't, manually create artist:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO artists (id, user_id, name, bio, specialties, is_active, slug)
|
||||||
|
VALUES ('uuid', 'user-id', 'Artist Name', '', '[]', 1, 'artist-name');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
After completing this setup:
|
||||||
|
|
||||||
|
1. **Monitor sign-ins**: Check server logs for any authentication errors
|
||||||
|
2. **Gather feedback**: Ask artists about their experience with the new login process
|
||||||
|
3. **Update documentation**: Keep this guide updated with any changes to the process
|
||||||
|
4. **Consider enhancements**:
|
||||||
|
- Sync artist profile photos from Nextcloud
|
||||||
|
- Enable calendar integration for all artists
|
||||||
|
- Add two-factor authentication requirement
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For technical support or questions about this integration, contact the development team or file an issue in the project repository.
|
||||||
|
|
||||||
|
Last updated: 2025-10-22
|
||||||
75
hooks/use-availability.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
|
||||||
|
interface AvailabilityResult {
|
||||||
|
available: boolean
|
||||||
|
reason?: string
|
||||||
|
checking: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseAvailabilityParams {
|
||||||
|
artistId: string | null
|
||||||
|
startTime: string | null
|
||||||
|
endTime: string | null
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAvailability({
|
||||||
|
artistId,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
enabled = true,
|
||||||
|
}: UseAvailabilityParams): AvailabilityResult {
|
||||||
|
const [result, setResult] = useState<AvailabilityResult>({
|
||||||
|
available: false,
|
||||||
|
checking: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const checkAvailability = useCallback(async () => {
|
||||||
|
if (!enabled || !artistId || !startTime || !endTime) {
|
||||||
|
setResult({ available: false, checking: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setResult(prev => ({ ...prev, checking: true, error: undefined }))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
artistId,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await fetch(`/api/caldav/availability?${params}`)
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to check availability')
|
||||||
|
}
|
||||||
|
|
||||||
|
setResult({
|
||||||
|
available: data.available,
|
||||||
|
reason: data.reason,
|
||||||
|
checking: false,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
setResult({
|
||||||
|
available: false,
|
||||||
|
checking: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to check availability',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [artistId, startTime, endTime, enabled])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Debounce the availability check
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
checkAvailability()
|
||||||
|
}, 300)
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [checkAvailability])
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
31
hooks/use-flash.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import type { FlashItem } from '@/types/database'
|
||||||
|
|
||||||
|
export const flashKeys = {
|
||||||
|
listByArtist: (artistId: string) => ['flash', 'list', artistId] as const,
|
||||||
|
item: (id: string) => ['flash', 'item', id] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFlash(artistId: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: flashKeys.listByArtist(artistId || ''),
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!artistId) return [] as FlashItem[]
|
||||||
|
const res = await fetch(`/api/flash/${artistId}`)
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch flash')
|
||||||
|
const data = await res.json()
|
||||||
|
return (data.items || []) as FlashItem[]
|
||||||
|
},
|
||||||
|
enabled: !!artistId,
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFlashItem(id: string): Promise<FlashItem | null> {
|
||||||
|
const res = await fetch(`/api/flash/item/${id}`)
|
||||||
|
if (!res.ok) return null
|
||||||
|
const data = await res.json()
|
||||||
|
return (data.item || null) as FlashItem | null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
66
lib/auth.ts
@ -4,21 +4,76 @@ import GitHubProvider from "next-auth/providers/github"
|
|||||||
import CredentialsProvider from "next-auth/providers/credentials"
|
import CredentialsProvider from "next-auth/providers/credentials"
|
||||||
import { env } from "./env"
|
import { env } from "./env"
|
||||||
import { UserRole } from "@/types/database"
|
import { UserRole } from "@/types/database"
|
||||||
|
import {
|
||||||
|
getNextcloudUserProfile,
|
||||||
|
getNextcloudUserGroups,
|
||||||
|
determineUserRole
|
||||||
|
} from "./nextcloud-client"
|
||||||
|
|
||||||
export const authOptions: NextAuthOptions = {
|
export const authOptions: NextAuthOptions = {
|
||||||
// Note: Database adapter will be configured via Supabase MCP
|
// Note: Database adapter will be configured via Supabase MCP
|
||||||
// For now, using JWT strategy without database adapter
|
// For now, using JWT strategy without database adapter
|
||||||
providers: [
|
providers: [
|
||||||
// Credentials provider for email/password login
|
// Credentials provider for email/password login (admin fallback) and Nextcloud OAuth completion
|
||||||
CredentialsProvider({
|
CredentialsProvider({
|
||||||
name: "credentials",
|
name: "credentials",
|
||||||
credentials: {
|
credentials: {
|
||||||
email: { label: "Email", type: "email" },
|
email: { label: "Email", type: "email" },
|
||||||
password: { label: "Password", type: "password" }
|
password: { label: "Password", type: "password" },
|
||||||
|
nextcloud_token: { label: "Nextcloud Token", type: "text" },
|
||||||
},
|
},
|
||||||
async authorize(credentials) {
|
async authorize(credentials, req) {
|
||||||
console.log("Authorize called with:", credentials)
|
console.log("Authorize called with:", credentials)
|
||||||
|
|
||||||
|
// Handle Nextcloud OAuth completion
|
||||||
|
if (credentials?.nextcloud_token) {
|
||||||
|
console.log("Nextcloud OAuth completion with token")
|
||||||
|
|
||||||
|
// Get cookies from request
|
||||||
|
const cookies = req.headers?.cookie
|
||||||
|
if (!cookies) {
|
||||||
|
console.error("No cookies found")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse cookies manually
|
||||||
|
const cookieMap = new Map(
|
||||||
|
cookies.split(';').map(c => {
|
||||||
|
const [key, ...values] = c.trim().split('=')
|
||||||
|
return [key, values.join('=')]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const storedToken = cookieMap.get('nextcloud_one_time_token')
|
||||||
|
const userId = cookieMap.get('nextcloud_user_id')
|
||||||
|
|
||||||
|
console.log("Stored token:", storedToken ? "present" : "missing")
|
||||||
|
console.log("User ID:", userId ? userId : "missing")
|
||||||
|
|
||||||
|
if (!storedToken || !userId || storedToken !== credentials.nextcloud_token) {
|
||||||
|
console.error("Token validation failed")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch user from database
|
||||||
|
const { getUserById } = await import('@/lib/db')
|
||||||
|
const user = await getUserById(userId)
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.error("User not found")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Nextcloud user authenticated:", user.email)
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle regular credentials login
|
||||||
if (!credentials?.email || !credentials?.password) {
|
if (!credentials?.email || !credentials?.password) {
|
||||||
console.log("Missing email or password")
|
console.log("Missing email or password")
|
||||||
return null
|
return null
|
||||||
@ -46,7 +101,7 @@ export const authOptions: NextAuthOptions = {
|
|||||||
name: credentials.email.split("@")[0],
|
name: credentials.email.split("@")[0],
|
||||||
role: UserRole.SUPER_ADMIN, // Give admin access for testing
|
role: UserRole.SUPER_ADMIN, // Give admin access for testing
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Created user:", user)
|
console.log("Created user:", user)
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
@ -92,6 +147,7 @@ export const authOptions: NextAuthOptions = {
|
|||||||
},
|
},
|
||||||
async signIn({ user, account, profile }) {
|
async signIn({ user, account, profile }) {
|
||||||
// Custom sign-in logic
|
// Custom sign-in logic
|
||||||
|
// Note: Nextcloud OAuth auto-provisioning happens in custom callback handler
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
async redirect({ url, baseUrl }) {
|
async redirect({ url, baseUrl }) {
|
||||||
|
|||||||
303
lib/caldav-client.ts
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
/**
|
||||||
|
* CalDAV Client for Nextcloud Integration
|
||||||
|
*
|
||||||
|
* This module provides functions to interact with Nextcloud CalDAV server,
|
||||||
|
* handling event creation, updates, deletions, and availability checks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DAVClient } from 'tsdav'
|
||||||
|
import ICAL from 'ical.js'
|
||||||
|
import type { Appointment, AppointmentStatus, CalendarEvent } from '@/types/database'
|
||||||
|
|
||||||
|
// Initialize CalDAV client with Nextcloud credentials
|
||||||
|
export function createCalDAVClient(): DAVClient | null {
|
||||||
|
const baseUrl = process.env.NEXTCLOUD_BASE_URL
|
||||||
|
const username = process.env.NEXTCLOUD_USERNAME
|
||||||
|
const password = process.env.NEXTCLOUD_PASSWORD
|
||||||
|
|
||||||
|
if (!baseUrl || !username || !password) {
|
||||||
|
console.warn('CalDAV credentials not configured. Calendar sync will be disabled.')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DAVClient({
|
||||||
|
serverUrl: baseUrl,
|
||||||
|
credentials: {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
},
|
||||||
|
authMethod: 'Basic',
|
||||||
|
defaultAccountType: 'caldav',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert appointment to iCalendar format
|
||||||
|
*/
|
||||||
|
export function appointmentToICalendar(appointment: Appointment, artistName: string, clientName: string): string {
|
||||||
|
const comp = new ICAL.Component(['vcalendar', [], []])
|
||||||
|
comp.updatePropertyWithValue('prodid', '-//United Tattoo//Booking System//EN')
|
||||||
|
comp.updatePropertyWithValue('version', '2.0')
|
||||||
|
|
||||||
|
const vevent = new ICAL.Component('vevent')
|
||||||
|
const event = new ICAL.Event(vevent)
|
||||||
|
|
||||||
|
// Set UID - use existing caldav_uid if available
|
||||||
|
event.uid = appointment.caldav_uid || `united-tattoo-${appointment.id}`
|
||||||
|
|
||||||
|
// Set summary based on appointment status
|
||||||
|
const summaryPrefix = appointment.status === 'PENDING' ? 'REQUEST: ' : ''
|
||||||
|
event.summary = `${summaryPrefix}${clientName} - ${appointment.title || 'Tattoo Session'}`
|
||||||
|
|
||||||
|
// Set description
|
||||||
|
const description = [
|
||||||
|
`Client: ${clientName}`,
|
||||||
|
`Artist: ${artistName}`,
|
||||||
|
appointment.description ? `Description: ${appointment.description}` : '',
|
||||||
|
appointment.placement ? `Placement: ${appointment.placement}` : '',
|
||||||
|
appointment.notes ? `Notes: ${appointment.notes}` : '',
|
||||||
|
`Status: ${appointment.status}`,
|
||||||
|
appointment.depositAmount ? `Deposit: $${appointment.depositAmount}` : '',
|
||||||
|
].filter(Boolean).join('\n')
|
||||||
|
|
||||||
|
event.description = description
|
||||||
|
|
||||||
|
// Set start and end times
|
||||||
|
const startTime = ICAL.Time.fromJSDate(new Date(appointment.startTime), true)
|
||||||
|
const endTime = ICAL.Time.fromJSDate(new Date(appointment.endTime), true)
|
||||||
|
|
||||||
|
event.startDate = startTime
|
||||||
|
event.endDate = endTime
|
||||||
|
|
||||||
|
// Add custom properties
|
||||||
|
vevent.addPropertyWithValue('x-appointment-id', appointment.id)
|
||||||
|
vevent.addPropertyWithValue('x-artist-id', appointment.artistId)
|
||||||
|
vevent.addPropertyWithValue('x-client-id', appointment.clientId)
|
||||||
|
vevent.addPropertyWithValue('x-appointment-status', appointment.status)
|
||||||
|
|
||||||
|
// Set status based on appointment status
|
||||||
|
if (appointment.status === 'CONFIRMED') {
|
||||||
|
vevent.addPropertyWithValue('status', 'CONFIRMED')
|
||||||
|
} else if (appointment.status === 'CANCELLED') {
|
||||||
|
vevent.addPropertyWithValue('status', 'CANCELLED')
|
||||||
|
} else {
|
||||||
|
vevent.addPropertyWithValue('status', 'TENTATIVE')
|
||||||
|
}
|
||||||
|
|
||||||
|
comp.addSubcomponent(vevent)
|
||||||
|
|
||||||
|
return comp.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse iCalendar event to CalendarEvent
|
||||||
|
*/
|
||||||
|
export function parseICalendarEvent(icsData: string): CalendarEvent | null {
|
||||||
|
try {
|
||||||
|
const jCalData = ICAL.parse(icsData)
|
||||||
|
const comp = new ICAL.Component(jCalData)
|
||||||
|
const vevent = comp.getFirstSubcomponent('vevent')
|
||||||
|
|
||||||
|
if (!vevent) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = new ICAL.Event(vevent)
|
||||||
|
|
||||||
|
return {
|
||||||
|
uid: event.uid,
|
||||||
|
summary: event.summary || '',
|
||||||
|
description: event.description || '',
|
||||||
|
startTime: event.startDate.toJSDate(),
|
||||||
|
endTime: event.endDate.toJSDate(),
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing iCalendar event:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or update an event in CalDAV server
|
||||||
|
*/
|
||||||
|
export async function createOrUpdateCalendarEvent(
|
||||||
|
client: DAVClient,
|
||||||
|
calendarUrl: string,
|
||||||
|
appointment: Appointment,
|
||||||
|
artistName: string,
|
||||||
|
clientName: string,
|
||||||
|
existingEtag?: string
|
||||||
|
): Promise<{ uid: string; etag?: string; url: string } | null> {
|
||||||
|
try {
|
||||||
|
await client.login()
|
||||||
|
|
||||||
|
const icsData = appointmentToICalendar(appointment, artistName, clientName)
|
||||||
|
const uid = appointment.caldav_uid || `united-tattoo-${appointment.id}`
|
||||||
|
|
||||||
|
// Construct the event URL
|
||||||
|
const eventUrl = `${calendarUrl}${uid}.ics`
|
||||||
|
|
||||||
|
// If we have an etag, this is an update
|
||||||
|
if (existingEtag) {
|
||||||
|
const response = await client.updateCalendarObject({
|
||||||
|
calendarObject: {
|
||||||
|
url: eventUrl,
|
||||||
|
data: icsData,
|
||||||
|
etag: existingEtag,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
uid,
|
||||||
|
etag: response.headers?.get('etag') || undefined,
|
||||||
|
url: eventUrl,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// This is a new event
|
||||||
|
const response = await client.createCalendarObject({
|
||||||
|
calendar: {
|
||||||
|
url: calendarUrl,
|
||||||
|
},
|
||||||
|
filename: `${uid}.ics`,
|
||||||
|
iCalString: icsData,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
uid,
|
||||||
|
etag: response.headers?.get('etag') || undefined,
|
||||||
|
url: eventUrl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating/updating calendar event:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an event from CalDAV server
|
||||||
|
*/
|
||||||
|
export async function deleteCalendarEvent(
|
||||||
|
client: DAVClient,
|
||||||
|
eventUrl: string,
|
||||||
|
etag?: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await client.login()
|
||||||
|
|
||||||
|
await client.deleteCalendarObject({
|
||||||
|
calendarObject: {
|
||||||
|
url: eventUrl,
|
||||||
|
etag: etag || '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting calendar event:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all events from a calendar within a date range
|
||||||
|
*/
|
||||||
|
export async function fetchCalendarEvents(
|
||||||
|
client: DAVClient,
|
||||||
|
calendarUrl: string,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date
|
||||||
|
): Promise<CalendarEvent[]> {
|
||||||
|
try {
|
||||||
|
await client.login()
|
||||||
|
|
||||||
|
const objects = await client.fetchCalendarObjects({
|
||||||
|
calendar: {
|
||||||
|
url: calendarUrl,
|
||||||
|
},
|
||||||
|
timeRange: {
|
||||||
|
start: startDate.toISOString(),
|
||||||
|
end: endDate.toISOString(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const events: CalendarEvent[] = []
|
||||||
|
|
||||||
|
for (const obj of objects) {
|
||||||
|
if (obj.data) {
|
||||||
|
const event = parseICalendarEvent(obj.data)
|
||||||
|
if (event) {
|
||||||
|
events.push({
|
||||||
|
...event,
|
||||||
|
etag: obj.etag,
|
||||||
|
url: obj.url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return events
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching calendar events:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a time slot is available (no conflicts)
|
||||||
|
*/
|
||||||
|
export async function checkTimeSlotAvailability(
|
||||||
|
client: DAVClient,
|
||||||
|
calendarUrl: string,
|
||||||
|
startTime: Date,
|
||||||
|
endTime: Date
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const events = await fetchCalendarEvents(client, calendarUrl, startTime, endTime)
|
||||||
|
|
||||||
|
// Check for any overlapping events
|
||||||
|
for (const event of events) {
|
||||||
|
const eventStart = new Date(event.startTime)
|
||||||
|
const eventEnd = new Date(event.endTime)
|
||||||
|
|
||||||
|
// Check for overlap
|
||||||
|
if (
|
||||||
|
(startTime >= eventStart && startTime < eventEnd) ||
|
||||||
|
(endTime > eventStart && endTime <= eventEnd) ||
|
||||||
|
(startTime <= eventStart && endTime >= eventEnd)
|
||||||
|
) {
|
||||||
|
return false // Slot is not available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true // Slot is available
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking time slot availability:', error)
|
||||||
|
// In case of error, assume slot is unavailable for safety
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all blocked time slots for a calendar within a date range
|
||||||
|
*/
|
||||||
|
export async function getBlockedTimeSlots(
|
||||||
|
client: DAVClient,
|
||||||
|
calendarUrl: string,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date
|
||||||
|
): Promise<Array<{ start: Date; end: Date; summary: string }>> {
|
||||||
|
try {
|
||||||
|
const events = await fetchCalendarEvents(client, calendarUrl, startDate, endDate)
|
||||||
|
|
||||||
|
return events.map(event => ({
|
||||||
|
start: new Date(event.startTime),
|
||||||
|
end: new Date(event.endTime),
|
||||||
|
summary: event.summary,
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting blocked time slots:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
458
lib/calendar-sync.ts
Normal file
@ -0,0 +1,458 @@
|
|||||||
|
/**
|
||||||
|
* Calendar Sync Service
|
||||||
|
*
|
||||||
|
* Handles bidirectional synchronization between database appointments
|
||||||
|
* and Nextcloud CalDAV calendars.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DAVClient } from 'tsdav'
|
||||||
|
import { getDB } from './db'
|
||||||
|
import {
|
||||||
|
createCalDAVClient,
|
||||||
|
createOrUpdateCalendarEvent,
|
||||||
|
deleteCalendarEvent,
|
||||||
|
fetchCalendarEvents,
|
||||||
|
} from './caldav-client'
|
||||||
|
import type { Appointment, CalendarSyncLog } from '@/types/database'
|
||||||
|
|
||||||
|
interface SyncResult {
|
||||||
|
success: boolean
|
||||||
|
error?: string
|
||||||
|
eventsProcessed: number
|
||||||
|
eventsCreated: number
|
||||||
|
eventsUpdated: number
|
||||||
|
eventsDeleted: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync a single appointment to CalDAV calendar
|
||||||
|
* Called when appointment is created/updated via web app
|
||||||
|
*/
|
||||||
|
export async function syncAppointmentToCalendar(
|
||||||
|
appointment: Appointment,
|
||||||
|
context?: any
|
||||||
|
): Promise<{ uid: string; etag?: string } | null> {
|
||||||
|
const client = createCalDAVClient()
|
||||||
|
if (!client) {
|
||||||
|
console.warn('CalDAV not configured, skipping sync')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = getDB(context?.env)
|
||||||
|
|
||||||
|
// Get artist calendar configuration
|
||||||
|
const calendarConfig = await db
|
||||||
|
.prepare('SELECT * FROM artist_calendars WHERE artist_id = ?')
|
||||||
|
.bind(appointment.artistId)
|
||||||
|
.first()
|
||||||
|
|
||||||
|
if (!calendarConfig) {
|
||||||
|
console.warn(`No calendar configured for artist ${appointment.artistId}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get artist and client names
|
||||||
|
const artist = await db
|
||||||
|
.prepare('SELECT name FROM artists WHERE id = ?')
|
||||||
|
.bind(appointment.artistId)
|
||||||
|
.first()
|
||||||
|
|
||||||
|
const client_user = await db
|
||||||
|
.prepare('SELECT name FROM users WHERE id = ?')
|
||||||
|
.bind(appointment.clientId)
|
||||||
|
.first()
|
||||||
|
|
||||||
|
const artistName = artist?.name || 'Unknown Artist'
|
||||||
|
const clientName = client_user?.name || 'Unknown Client'
|
||||||
|
|
||||||
|
// Create or update event in CalDAV
|
||||||
|
const result = await createOrUpdateCalendarEvent(
|
||||||
|
client,
|
||||||
|
calendarConfig.calendar_url,
|
||||||
|
appointment,
|
||||||
|
artistName,
|
||||||
|
clientName,
|
||||||
|
appointment.caldav_etag || undefined
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
// Update appointment with CalDAV UID and ETag
|
||||||
|
await db
|
||||||
|
.prepare('UPDATE appointments SET caldav_uid = ?, caldav_etag = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
|
||||||
|
.bind(result.uid, result.etag || null, appointment.id)
|
||||||
|
.run()
|
||||||
|
|
||||||
|
return { uid: result.uid, etag: result.etag }
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error syncing appointment to calendar:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete appointment from CalDAV calendar
|
||||||
|
* Called when appointment is cancelled or deleted
|
||||||
|
*/
|
||||||
|
export async function deleteAppointmentFromCalendar(
|
||||||
|
appointment: Appointment,
|
||||||
|
context?: any
|
||||||
|
): Promise<boolean> {
|
||||||
|
const client = createCalDAVClient()
|
||||||
|
if (!client) {
|
||||||
|
console.warn('CalDAV not configured, skipping delete')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = getDB(context?.env)
|
||||||
|
|
||||||
|
// Get artist calendar configuration
|
||||||
|
const calendarConfig = await db
|
||||||
|
.prepare('SELECT * FROM artist_calendars WHERE artist_id = ?')
|
||||||
|
.bind(appointment.artistId)
|
||||||
|
.first()
|
||||||
|
|
||||||
|
if (!calendarConfig || !appointment.caldav_uid) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct event URL
|
||||||
|
const eventUrl = `${calendarConfig.calendar_url}${appointment.caldav_uid}.ics`
|
||||||
|
|
||||||
|
// Delete from CalDAV
|
||||||
|
const success = await deleteCalendarEvent(client, eventUrl, appointment.caldav_etag || undefined)
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// Clear CalDAV fields in database
|
||||||
|
await db
|
||||||
|
.prepare('UPDATE appointments SET caldav_uid = NULL, caldav_etag = NULL WHERE id = ?')
|
||||||
|
.bind(appointment.id)
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
return success
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting appointment from calendar:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull calendar events from Nextcloud and sync to database
|
||||||
|
* This is called by background worker or manual sync
|
||||||
|
*/
|
||||||
|
export async function pullCalendarEventsToDatabase(
|
||||||
|
artistId: string,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
|
context?: any
|
||||||
|
): Promise<SyncResult> {
|
||||||
|
const result: SyncResult = {
|
||||||
|
success: false,
|
||||||
|
eventsProcessed: 0,
|
||||||
|
eventsCreated: 0,
|
||||||
|
eventsUpdated: 0,
|
||||||
|
eventsDeleted: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = createCalDAVClient()
|
||||||
|
if (!client) {
|
||||||
|
result.error = 'CalDAV not configured'
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = getDB(context?.env)
|
||||||
|
|
||||||
|
// Get artist calendar configuration
|
||||||
|
const calendarConfig = await db
|
||||||
|
.prepare('SELECT * FROM artist_calendars WHERE artist_id = ?')
|
||||||
|
.bind(artistId)
|
||||||
|
.first()
|
||||||
|
|
||||||
|
if (!calendarConfig) {
|
||||||
|
result.error = `No calendar configured for artist ${artistId}`
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch events from CalDAV
|
||||||
|
const calendarEvents = await fetchCalendarEvents(
|
||||||
|
client,
|
||||||
|
calendarConfig.calendar_url,
|
||||||
|
startDate,
|
||||||
|
endDate
|
||||||
|
)
|
||||||
|
|
||||||
|
result.eventsProcessed = calendarEvents.length
|
||||||
|
|
||||||
|
// Get existing appointments for this artist in the date range
|
||||||
|
const existingAppointments = await db
|
||||||
|
.prepare(`
|
||||||
|
SELECT * FROM appointments
|
||||||
|
WHERE artist_id = ?
|
||||||
|
AND start_time >= ?
|
||||||
|
AND end_time <= ?
|
||||||
|
AND caldav_uid IS NOT NULL
|
||||||
|
`)
|
||||||
|
.bind(artistId, startDate.toISOString(), endDate.toISOString())
|
||||||
|
.all()
|
||||||
|
|
||||||
|
const existingUids = new Set(
|
||||||
|
existingAppointments.results.map((a: any) => a.caldav_uid)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Process each calendar event
|
||||||
|
for (const event of calendarEvents) {
|
||||||
|
// Check if this event exists in our database
|
||||||
|
const existing = await db
|
||||||
|
.prepare('SELECT * FROM appointments WHERE caldav_uid = ? AND artist_id = ?')
|
||||||
|
.bind(event.uid, artistId)
|
||||||
|
.first()
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Update existing appointment if needed
|
||||||
|
// Only update if the calendar event is different
|
||||||
|
const eventChanged =
|
||||||
|
new Date(existing.start_time).getTime() !== event.startTime.getTime() ||
|
||||||
|
new Date(existing.end_time).getTime() !== event.endTime.getTime() ||
|
||||||
|
existing.title !== event.summary
|
||||||
|
|
||||||
|
if (eventChanged) {
|
||||||
|
await db
|
||||||
|
.prepare(`
|
||||||
|
UPDATE appointments
|
||||||
|
SET title = ?, description = ?, start_time = ?, end_time = ?, caldav_etag = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE caldav_uid = ? AND artist_id = ?
|
||||||
|
`)
|
||||||
|
.bind(
|
||||||
|
event.summary,
|
||||||
|
event.description || existing.description,
|
||||||
|
event.startTime.toISOString(),
|
||||||
|
event.endTime.toISOString(),
|
||||||
|
event.etag || null,
|
||||||
|
event.uid,
|
||||||
|
artistId
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
|
||||||
|
result.eventsUpdated++
|
||||||
|
}
|
||||||
|
|
||||||
|
existingUids.delete(event.uid)
|
||||||
|
} else {
|
||||||
|
// This is a new event from calendar - create appointment
|
||||||
|
// We'll create it as CONFIRMED since it came from the calendar
|
||||||
|
const appointmentId = crypto.randomUUID()
|
||||||
|
|
||||||
|
// Get or create a system user for calendar-sourced appointments
|
||||||
|
let systemUser = await db
|
||||||
|
.prepare('SELECT id FROM users WHERE email = ?')
|
||||||
|
.bind('calendar@system.local')
|
||||||
|
.first()
|
||||||
|
|
||||||
|
if (!systemUser) {
|
||||||
|
const userId = crypto.randomUUID()
|
||||||
|
await db
|
||||||
|
.prepare('INSERT INTO users (id, email, name, role, created_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)')
|
||||||
|
.bind(userId, 'calendar@system.local', 'Calendar System', 'CLIENT')
|
||||||
|
.run()
|
||||||
|
systemUser = { id: userId }
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.prepare(`
|
||||||
|
INSERT INTO appointments (
|
||||||
|
id, artist_id, client_id, title, description, start_time, end_time,
|
||||||
|
status, caldav_uid, caldav_etag, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, 'CONFIRMED', ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
`)
|
||||||
|
.bind(
|
||||||
|
appointmentId,
|
||||||
|
artistId,
|
||||||
|
systemUser.id,
|
||||||
|
event.summary,
|
||||||
|
event.description || '',
|
||||||
|
event.startTime.toISOString(),
|
||||||
|
event.endTime.toISOString(),
|
||||||
|
event.uid,
|
||||||
|
event.etag || null
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
|
||||||
|
result.eventsCreated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete appointments that no longer exist in calendar
|
||||||
|
for (const uid of existingUids) {
|
||||||
|
await db
|
||||||
|
.prepare('DELETE FROM appointments WHERE caldav_uid = ? AND artist_id = ?')
|
||||||
|
.bind(uid, artistId)
|
||||||
|
.run()
|
||||||
|
|
||||||
|
result.eventsDeleted++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sync timestamp
|
||||||
|
await db
|
||||||
|
.prepare('UPDATE artist_calendars SET last_sync_at = CURRENT_TIMESTAMP WHERE artist_id = ?')
|
||||||
|
.bind(artistId)
|
||||||
|
.run()
|
||||||
|
|
||||||
|
result.success = true
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error pulling calendar events:', error)
|
||||||
|
result.error = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check availability for a specific artist and time slot
|
||||||
|
*/
|
||||||
|
export async function checkArtistAvailability(
|
||||||
|
artistId: string,
|
||||||
|
startTime: Date,
|
||||||
|
endTime: Date,
|
||||||
|
context?: any
|
||||||
|
): Promise<{ available: boolean; reason?: string }> {
|
||||||
|
const client = createCalDAVClient()
|
||||||
|
if (!client) {
|
||||||
|
// If CalDAV is not configured, fall back to database-only check
|
||||||
|
return checkDatabaseAvailability(artistId, startTime, endTime, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = getDB(context?.env)
|
||||||
|
|
||||||
|
// Get artist calendar configuration
|
||||||
|
const calendarConfig = await db
|
||||||
|
.prepare('SELECT * FROM artist_calendars WHERE artist_id = ?')
|
||||||
|
.bind(artistId)
|
||||||
|
.first()
|
||||||
|
|
||||||
|
if (!calendarConfig) {
|
||||||
|
// Fall back to database check
|
||||||
|
return checkDatabaseAvailability(artistId, startTime, endTime, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check calendar for conflicts (extend range slightly for buffer)
|
||||||
|
const bufferMinutes = 15
|
||||||
|
const checkStart = new Date(startTime.getTime() - bufferMinutes * 60 * 1000)
|
||||||
|
const checkEnd = new Date(endTime.getTime() + bufferMinutes * 60 * 1000)
|
||||||
|
|
||||||
|
const events = await fetchCalendarEvents(
|
||||||
|
client,
|
||||||
|
calendarConfig.calendar_url,
|
||||||
|
checkStart,
|
||||||
|
checkEnd
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check for overlapping events
|
||||||
|
for (const event of events) {
|
||||||
|
const eventStart = new Date(event.startTime)
|
||||||
|
const eventEnd = new Date(event.endTime)
|
||||||
|
|
||||||
|
if (
|
||||||
|
(startTime >= eventStart && startTime < eventEnd) ||
|
||||||
|
(endTime > eventStart && endTime <= eventEnd) ||
|
||||||
|
(startTime <= eventStart && endTime >= eventEnd)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
reason: `Time slot conflicts with: ${event.summary}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { available: true }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking artist availability:', error)
|
||||||
|
// Fall back to database check on error
|
||||||
|
return checkDatabaseAvailability(artistId, startTime, endTime, context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database-only availability check (fallback when CalDAV unavailable)
|
||||||
|
*/
|
||||||
|
async function checkDatabaseAvailability(
|
||||||
|
artistId: string,
|
||||||
|
startTime: Date,
|
||||||
|
endTime: Date,
|
||||||
|
context?: any
|
||||||
|
): Promise<{ available: boolean; reason?: string }> {
|
||||||
|
const db = getDB(context?.env)
|
||||||
|
|
||||||
|
const conflicts = await db
|
||||||
|
.prepare(`
|
||||||
|
SELECT id, title FROM appointments
|
||||||
|
WHERE artist_id = ?
|
||||||
|
AND status NOT IN ('CANCELLED', 'COMPLETED')
|
||||||
|
AND (
|
||||||
|
(start_time <= ? AND end_time > ?) OR
|
||||||
|
(start_time < ? AND end_time >= ?) OR
|
||||||
|
(start_time >= ? AND end_time <= ?)
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.bind(
|
||||||
|
artistId,
|
||||||
|
startTime.toISOString(), startTime.toISOString(),
|
||||||
|
endTime.toISOString(), endTime.toISOString(),
|
||||||
|
startTime.toISOString(), endTime.toISOString()
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
|
||||||
|
if (conflicts.results.length > 0) {
|
||||||
|
const conflict = conflicts.results[0] as any
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
reason: `Time slot conflicts with existing appointment: ${conflict.title}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { available: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log sync operation to database
|
||||||
|
*/
|
||||||
|
export async function logSync(
|
||||||
|
log: Omit<CalendarSyncLog, 'id' | 'createdAt'>,
|
||||||
|
context?: any
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const db = getDB(context?.env)
|
||||||
|
const logId = crypto.randomUUID()
|
||||||
|
|
||||||
|
await db
|
||||||
|
.prepare(`
|
||||||
|
INSERT INTO calendar_sync_logs (
|
||||||
|
id, artist_id, sync_type, status, error_message,
|
||||||
|
events_processed, events_created, events_updated, events_deleted,
|
||||||
|
duration_ms, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
`)
|
||||||
|
.bind(
|
||||||
|
logId,
|
||||||
|
log.artistId || null,
|
||||||
|
log.syncType,
|
||||||
|
log.status,
|
||||||
|
log.errorMessage || null,
|
||||||
|
log.eventsProcessed,
|
||||||
|
log.eventsCreated,
|
||||||
|
log.eventsUpdated,
|
||||||
|
log.eventsDeleted,
|
||||||
|
log.durationMs || null
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error logging sync:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
176
lib/db.ts
@ -1,13 +1,16 @@
|
|||||||
import type {
|
import type {
|
||||||
Artist,
|
Artist,
|
||||||
PortfolioImage,
|
PortfolioImage,
|
||||||
Appointment,
|
Appointment,
|
||||||
SiteSettings,
|
SiteSettings,
|
||||||
CreateArtistInput,
|
CreateArtistInput,
|
||||||
UpdateArtistInput,
|
UpdateArtistInput,
|
||||||
CreateAppointmentInput,
|
CreateAppointmentInput,
|
||||||
UpdateSiteSettingsInput,
|
UpdateSiteSettingsInput,
|
||||||
AppointmentFilters
|
AppointmentFilters,
|
||||||
|
FlashItem,
|
||||||
|
User,
|
||||||
|
UserRole
|
||||||
} from '@/types/database'
|
} from '@/types/database'
|
||||||
|
|
||||||
// Type for Cloudflare D1 database binding
|
// Type for Cloudflare D1 database binding
|
||||||
@ -36,6 +39,127 @@ export function getDB(env?: any): D1Database {
|
|||||||
return db as D1Database;
|
return db as D1Database;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User Management Functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function getUserByEmail(email: string, env?: any): Promise<User | null> {
|
||||||
|
const db = getDB(env);
|
||||||
|
const result = await db.prepare(`
|
||||||
|
SELECT * FROM users WHERE email = ?
|
||||||
|
`).bind(email).first();
|
||||||
|
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: result.id as string,
|
||||||
|
email: result.email as string,
|
||||||
|
name: result.name as string,
|
||||||
|
role: result.role as UserRole,
|
||||||
|
avatar: result.avatar as string | undefined,
|
||||||
|
createdAt: new Date(result.created_at as string),
|
||||||
|
updatedAt: new Date(result.updated_at as string),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserById(id: string, env?: any): Promise<User | null> {
|
||||||
|
const db = getDB(env);
|
||||||
|
const result = await db.prepare(`
|
||||||
|
SELECT * FROM users WHERE id = ?
|
||||||
|
`).bind(id).first();
|
||||||
|
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: result.id as string,
|
||||||
|
email: result.email as string,
|
||||||
|
name: result.name as string,
|
||||||
|
role: result.role as UserRole,
|
||||||
|
avatar: result.avatar as string | undefined,
|
||||||
|
createdAt: new Date(result.created_at as string),
|
||||||
|
updatedAt: new Date(result.updated_at as string),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(data: {
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
role: UserRole
|
||||||
|
avatar?: string
|
||||||
|
}, env?: any): Promise<User> {
|
||||||
|
const db = getDB(env);
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
await db.prepare(`
|
||||||
|
INSERT INTO users (id, email, name, role, avatar, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).bind(
|
||||||
|
id,
|
||||||
|
data.email,
|
||||||
|
data.name,
|
||||||
|
data.role,
|
||||||
|
data.avatar || null,
|
||||||
|
now,
|
||||||
|
now
|
||||||
|
).run();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
email: data.email,
|
||||||
|
name: data.name,
|
||||||
|
role: data.role,
|
||||||
|
avatar: data.avatar,
|
||||||
|
createdAt: new Date(now),
|
||||||
|
updatedAt: new Date(now),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(id: string, data: {
|
||||||
|
email?: string
|
||||||
|
name?: string
|
||||||
|
role?: UserRole
|
||||||
|
avatar?: string
|
||||||
|
}, env?: any): Promise<User> {
|
||||||
|
const db = getDB(env);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const updates: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
|
||||||
|
if (data.email !== undefined) {
|
||||||
|
updates.push('email = ?');
|
||||||
|
values.push(data.email);
|
||||||
|
}
|
||||||
|
if (data.name !== undefined) {
|
||||||
|
updates.push('name = ?');
|
||||||
|
values.push(data.name);
|
||||||
|
}
|
||||||
|
if (data.role !== undefined) {
|
||||||
|
updates.push('role = ?');
|
||||||
|
values.push(data.role);
|
||||||
|
}
|
||||||
|
if (data.avatar !== undefined) {
|
||||||
|
updates.push('avatar = ?');
|
||||||
|
values.push(data.avatar);
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push('updated_at = ?');
|
||||||
|
values.push(now);
|
||||||
|
values.push(id);
|
||||||
|
|
||||||
|
await db.prepare(`
|
||||||
|
UPDATE users SET ${updates.join(', ')} WHERE id = ?
|
||||||
|
`).bind(...values).run();
|
||||||
|
|
||||||
|
const updated = await getUserById(id, env);
|
||||||
|
if (!updated) {
|
||||||
|
throw new Error(`Failed to update user ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Artist Management Functions
|
* Artist Management Functions
|
||||||
*/
|
*/
|
||||||
@ -163,6 +287,20 @@ export async function getArtistWithPortfolio(id: string, env?: any): Promise<imp
|
|||||||
ORDER BY order_index ASC, created_at DESC
|
ORDER BY order_index ASC, created_at DESC
|
||||||
`).bind(id).all();
|
`).bind(id).all();
|
||||||
|
|
||||||
|
// Fetch flash items (public only) - tolerate missing table in older DBs
|
||||||
|
let flashRows: any[] = []
|
||||||
|
try {
|
||||||
|
const flashResult = await db.prepare(`
|
||||||
|
SELECT * FROM flash_items
|
||||||
|
WHERE artist_id = ? AND is_available = 1
|
||||||
|
ORDER BY order_index ASC, created_at DESC
|
||||||
|
`).bind(id).all();
|
||||||
|
flashRows = flashResult.results as any[]
|
||||||
|
} catch (_err) {
|
||||||
|
// Table may not exist yet; treat as empty
|
||||||
|
flashRows = []
|
||||||
|
}
|
||||||
|
|
||||||
const artist = artistResult as any;
|
const artist = artistResult as any;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -185,6 +323,20 @@ export async function getArtistWithPortfolio(id: string, env?: any): Promise<imp
|
|||||||
isPublic: Boolean(img.is_public),
|
isPublic: Boolean(img.is_public),
|
||||||
createdAt: new Date(img.created_at)
|
createdAt: new Date(img.created_at)
|
||||||
})),
|
})),
|
||||||
|
// Attach as non-breaking field (not in Artist type but useful to callers)
|
||||||
|
flashItems: flashRows.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
artistId: row.artist_id,
|
||||||
|
url: row.url,
|
||||||
|
title: row.title || undefined,
|
||||||
|
description: row.description || undefined,
|
||||||
|
price: row.price ?? undefined,
|
||||||
|
sizeHint: row.size_hint || undefined,
|
||||||
|
tags: row.tags ? JSON.parse(row.tags) : undefined,
|
||||||
|
orderIndex: row.order_index || 0,
|
||||||
|
isAvailable: Boolean(row.is_available),
|
||||||
|
createdAt: new Date(row.created_at)
|
||||||
|
})) as FlashItem[],
|
||||||
availability: [],
|
availability: [],
|
||||||
createdAt: new Date(artist.created_at),
|
createdAt: new Date(artist.created_at),
|
||||||
updatedAt: new Date(artist.updated_at),
|
updatedAt: new Date(artist.updated_at),
|
||||||
@ -676,6 +828,12 @@ export async function updateSiteSettings(data: UpdateSiteSettingsInput, env?: an
|
|||||||
|
|
||||||
// Type-safe query builder helpers
|
// Type-safe query builder helpers
|
||||||
export const db = {
|
export const db = {
|
||||||
|
users: {
|
||||||
|
findByEmail: getUserByEmail,
|
||||||
|
findById: getUserById,
|
||||||
|
create: createUser,
|
||||||
|
update: updateUser,
|
||||||
|
},
|
||||||
artists: {
|
artists: {
|
||||||
findMany: getArtists,
|
findMany: getArtists,
|
||||||
findUnique: getArtist,
|
findUnique: getArtist,
|
||||||
|
|||||||
12
lib/env.ts
@ -33,6 +33,18 @@ const envSchema = z.object({
|
|||||||
|
|
||||||
// Optional: Analytics
|
// Optional: Analytics
|
||||||
VERCEL_ANALYTICS_ID: z.string().optional(),
|
VERCEL_ANALYTICS_ID: z.string().optional(),
|
||||||
|
|
||||||
|
// CalDAV / Nextcloud Integration
|
||||||
|
NEXTCLOUD_BASE_URL: z.string().url().optional(),
|
||||||
|
NEXTCLOUD_USERNAME: z.string().optional(),
|
||||||
|
NEXTCLOUD_PASSWORD: z.string().optional(),
|
||||||
|
NEXTCLOUD_CALENDAR_BASE_PATH: z.string().default('/remote.php/dav/calendars'),
|
||||||
|
|
||||||
|
// Nextcloud OAuth Authentication
|
||||||
|
NEXTCLOUD_OAUTH_CLIENT_ID: z.string().optional(),
|
||||||
|
NEXTCLOUD_OAUTH_CLIENT_SECRET: z.string().optional(),
|
||||||
|
NEXTCLOUD_ARTISTS_GROUP: z.string().default('artists'),
|
||||||
|
NEXTCLOUD_ADMINS_GROUP: z.string().default('shop_admins'),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type Env = z.infer<typeof envSchema>
|
export type Env = z.infer<typeof envSchema>
|
||||||
|
|||||||
180
lib/nextcloud-client.ts
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* Nextcloud API Client
|
||||||
|
*
|
||||||
|
* Provides functions to interact with Nextcloud OCS (Open Collaboration Services) API
|
||||||
|
* for user management and group membership checking during OAuth authentication.
|
||||||
|
*
|
||||||
|
* API Documentation: https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/index.html
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface NextcloudUserProfile {
|
||||||
|
id: string
|
||||||
|
enabled: boolean
|
||||||
|
email: string
|
||||||
|
displayname: string
|
||||||
|
groups: string[]
|
||||||
|
quota?: {
|
||||||
|
free: number
|
||||||
|
used: number
|
||||||
|
total: number
|
||||||
|
relative: number
|
||||||
|
quota: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NextcloudOCSResponse<T> {
|
||||||
|
ocs: {
|
||||||
|
meta: {
|
||||||
|
status: string
|
||||||
|
statuscode: number
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get authenticated user's profile from Nextcloud
|
||||||
|
* Uses OCS API with Basic Auth (service account credentials)
|
||||||
|
*
|
||||||
|
* @param userId Nextcloud user ID
|
||||||
|
* @returns User profile including groups, email, and display name
|
||||||
|
*/
|
||||||
|
export async function getNextcloudUserProfile(
|
||||||
|
userId: string
|
||||||
|
): Promise<NextcloudUserProfile | null> {
|
||||||
|
const baseUrl = process.env.NEXTCLOUD_BASE_URL
|
||||||
|
const username = process.env.NEXTCLOUD_USERNAME
|
||||||
|
const password = process.env.NEXTCLOUD_PASSWORD
|
||||||
|
|
||||||
|
if (!baseUrl || !username || !password) {
|
||||||
|
console.error('Nextcloud credentials not configured for user API access')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `${baseUrl}/ocs/v1.php/cloud/users/${encodeURIComponent(userId)}`
|
||||||
|
const auth = Buffer.from(`${username}:${password}`).toString('base64')
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${auth}`,
|
||||||
|
'OCS-APIRequest': 'true',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`Failed to fetch Nextcloud user profile: ${response.status} ${response.statusText}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as NextcloudOCSResponse<NextcloudUserProfile>
|
||||||
|
|
||||||
|
if (data.ocs.meta.statuscode !== 100) {
|
||||||
|
console.error(`Nextcloud API error: ${data.ocs.meta.message}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.ocs.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching Nextcloud user profile:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's group memberships from Nextcloud
|
||||||
|
*
|
||||||
|
* @param userId Nextcloud user ID
|
||||||
|
* @returns Array of group names the user belongs to
|
||||||
|
*/
|
||||||
|
export async function getNextcloudUserGroups(
|
||||||
|
userId: string
|
||||||
|
): Promise<string[]> {
|
||||||
|
const baseUrl = process.env.NEXTCLOUD_BASE_URL
|
||||||
|
const username = process.env.NEXTCLOUD_USERNAME
|
||||||
|
const password = process.env.NEXTCLOUD_PASSWORD
|
||||||
|
|
||||||
|
if (!baseUrl || !username || !password) {
|
||||||
|
console.error('Nextcloud credentials not configured for group API access')
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `${baseUrl}/ocs/v1.php/cloud/users/${encodeURIComponent(userId)}/groups`
|
||||||
|
const auth = Buffer.from(`${username}:${password}`).toString('base64')
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${auth}`,
|
||||||
|
'OCS-APIRequest': 'true',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`Failed to fetch Nextcloud user groups: ${response.status} ${response.statusText}`)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as NextcloudOCSResponse<{ groups: string[] }>
|
||||||
|
|
||||||
|
if (data.ocs.meta.statuscode !== 100) {
|
||||||
|
console.error(`Nextcloud API error: ${data.ocs.meta.message}`)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.ocs.data.groups
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching Nextcloud user groups:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user belongs to a specific group in Nextcloud
|
||||||
|
*
|
||||||
|
* @param userId Nextcloud user ID
|
||||||
|
* @param groupName Group name to check
|
||||||
|
* @returns True if user is in the group, false otherwise
|
||||||
|
*/
|
||||||
|
export async function isUserInGroup(
|
||||||
|
userId: string,
|
||||||
|
groupName: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const groups = await getNextcloudUserGroups(userId)
|
||||||
|
return groups.includes(groupName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the appropriate role for a user based on their Nextcloud group memberships
|
||||||
|
*
|
||||||
|
* @param userId Nextcloud user ID
|
||||||
|
* @returns Role: 'SUPER_ADMIN', 'SHOP_ADMIN', 'ARTIST', or 'CLIENT'
|
||||||
|
*/
|
||||||
|
export async function determineUserRole(
|
||||||
|
userId: string
|
||||||
|
): Promise<'SUPER_ADMIN' | 'SHOP_ADMIN' | 'ARTIST' | 'CLIENT'> {
|
||||||
|
const groups = await getNextcloudUserGroups(userId)
|
||||||
|
|
||||||
|
const adminsGroup = process.env.NEXTCLOUD_ADMINS_GROUP || 'shop_admins'
|
||||||
|
const artistsGroup = process.env.NEXTCLOUD_ARTISTS_GROUP || 'artists'
|
||||||
|
|
||||||
|
// Check for admin groups first (higher priority)
|
||||||
|
if (groups.includes('admin') || groups.includes('admins')) {
|
||||||
|
return 'SUPER_ADMIN'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groups.includes(adminsGroup)) {
|
||||||
|
return 'SHOP_ADMIN'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for artist group
|
||||||
|
if (groups.includes(artistsGroup)) {
|
||||||
|
return 'ARTIST'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to client role
|
||||||
|
return 'CLIENT'
|
||||||
|
}
|
||||||
@ -7,6 +7,13 @@ export default withAuth(
|
|||||||
const token = req.nextauth.token
|
const token = req.nextauth.token
|
||||||
const { pathname } = req.nextUrl
|
const { pathname } = req.nextUrl
|
||||||
|
|
||||||
|
// Permanent redirect for renamed artist slug
|
||||||
|
if (pathname === "/artists/amari-rodriguez") {
|
||||||
|
const url = new URL("/artists/amari-kyss", req.url)
|
||||||
|
const res = NextResponse.redirect(url, 308)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
// Allow token-based bypass for admin migrate endpoint (non-interactive deployments)
|
// Allow token-based bypass for admin migrate endpoint (non-interactive deployments)
|
||||||
const migrateToken = process.env.MIGRATE_TOKEN
|
const migrateToken = process.env.MIGRATE_TOKEN
|
||||||
const headerToken = req.headers.get("x-migrate-token")
|
const headerToken = req.headers.get("x-migrate-token")
|
||||||
|
|||||||
955
package-lock.json
generated
@ -92,11 +92,13 @@
|
|||||||
"cmdk": "latest",
|
"cmdk": "latest",
|
||||||
"date-fns": "latest",
|
"date-fns": "latest",
|
||||||
"embla-carousel-react": "8.5.1",
|
"embla-carousel-react": "8.5.1",
|
||||||
|
"framer-motion": "^12.23.24",
|
||||||
"geist": "^1.3.1",
|
"geist": "^1.3.1",
|
||||||
|
"ical.js": "^1.5.0",
|
||||||
"input-otp": "latest",
|
"input-otp": "latest",
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^0.454.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"next": "14.2.16",
|
"next": "^14.2.33",
|
||||||
"next-auth": "^4.24.11",
|
"next-auth": "^4.24.11",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
@ -110,6 +112,7 @@
|
|||||||
"sonner": "^1.7.4",
|
"sonner": "^1.7.4",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"tsdav": "^2.1.5",
|
||||||
"vaul": "^0.9.9",
|
"vaul": "^0.9.9",
|
||||||
"zod": "3.25.67"
|
"zod": "3.25.67"
|
||||||
},
|
},
|
||||||
@ -126,8 +129,10 @@
|
|||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-config-next": "14.2.16",
|
"eslint-config-next": "14.2.16",
|
||||||
|
"heic-convert": "^2.1.0",
|
||||||
"jsdom": "^27.0.0",
|
"jsdom": "^27.0.0",
|
||||||
"postcss": "^8.5",
|
"postcss": "^8.5",
|
||||||
|
"sharp": "^0.34.4",
|
||||||
"tailwindcss": "^4.1.9",
|
"tailwindcss": "^4.1.9",
|
||||||
"tw-animate-css": "1.3.3",
|
"tw-animate-css": "1.3.3",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
|
|||||||
BIN
public/artists/Amari-Rodriguez/Bio/United Artist Bio.pdf
Normal file
138
public/artists/Amari-Rodriguez/EDIT ME.md
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
# Tattoo Artist Portfolio Questionnaire
|
||||||
|
|
||||||
|
## Basic Information
|
||||||
|
|
||||||
|
**Artist Name/Alias: Amari Kyss**
|
||||||
|
|
||||||
|
**Contact Email: grimmtatt@gmail.com**
|
||||||
|
|
||||||
|
**Instagram Handle:@grimmtatt**
|
||||||
|
|
||||||
|
**Other Social Media/Website:** <https://grimmtatts.glossgenius.com/>
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
**How did you get started in tattooing? In my Mothers House**
|
||||||
|
|
||||||
|
**Who were your mentors or influences? Christy Lumberg**
|
||||||
|
|
||||||
|
**In 2-3 paragraphs, describe your artistic philosophy and what makes your work unique:**
|
||||||
|
|
||||||
|
i think what sets me apart isn’t just how i tattoo it’s how i care. i don’t want this to feel like a
|
||||||
|
|
||||||
|
transaction, like you’re ordering a tattoo the way you’d order a meal. this isn’t fast, or
|
||||||
|
|
||||||
|
disposable, or something to rush through. i want every person who sits in my chair to feel like
|
||||||
|
|
||||||
|
they’re seen, like their story matters, and like the art we make together is something sacred
|
||||||
|
|
||||||
|
even if it’s small. i know i didn’t invent traditional tattooing, and i’m not pretending to be the
|
||||||
|
|
||||||
|
first person to lead with kindness. what i am is genuine. consistent. thoughtful. i approach this
|
||||||
|
|
||||||
|
work with deep respect for the history of it, for the people who wear it, and for the trust that
|
||||||
|
|
||||||
|
comes with putting something permanent on someone’s body. i’d do this for free if the world
|
||||||
|
|
||||||
|
let me. because to me, tattooing isn’t just a job for me it’s an exchange of energy, of care, of time. and
|
||||||
|
|
||||||
|
i think that intention lives in every piece i put out.
|
||||||
|
|
||||||
|
**What do you want potential clients to know about you and your work?**
|
||||||
|
|
||||||
|
i’d want them to know it feels like hanging out with someone they could actually be friends with
|
||||||
|
|
||||||
|
outside of the tattoo. like it was easy, comforting, and they didn’t have to be anything but
|
||||||
|
|
||||||
|
themselves. no pressure to be confident or outgoing or have the perfect idea or body just come
|
||||||
|
|
||||||
|
as you are, and that’s more than enough. i really try to create a space where people feel safe
|
||||||
|
|
||||||
|
and accepted. your body is welcome here. your story is welcome here. i want it to feel like
|
||||||
|
|
||||||
|
you’re just spending time with someone who sees you, hears you, and wants you to leave
|
||||||
|
|
||||||
|
feeling a little more at home in yourself.
|
||||||
|
|
||||||
|
**What are your goals for your tattoo career in the next few years?**
|
||||||
|
|
||||||
|
**slang insane ink**
|
||||||
|
|
||||||
|
## Artistic Style & Specialties
|
||||||
|
|
||||||
|
**What tattoo styles do you specialize in?** (Check all that apply)
|
||||||
|
|
||||||
|
- \[ x\] Traditional/American Traditional
|
||||||
|
- \[x \] Neo-Traditional
|
||||||
|
- \[ \] Realism (Black & Grey)
|
||||||
|
- \[ \] Realism (Color)
|
||||||
|
- \[x \] Japanese/Irezumi
|
||||||
|
- \[x \] Blackwork
|
||||||
|
- \[x \] Fine Line
|
||||||
|
- \[ \] Geometric
|
||||||
|
- \[ \] Watercolor
|
||||||
|
- \[ \] Tribal
|
||||||
|
- \[ \] Portrait
|
||||||
|
- \[ x\] Lettering/Script
|
||||||
|
- \[ \] Illustrative
|
||||||
|
- \[x \] Dotwork
|
||||||
|
- \[ \] Biomechanical
|
||||||
|
- \[x \] Cover-ups
|
||||||
|
- \[ \] Other: \________________\_
|
||||||
|
|
||||||
|
**What are your top 3 favorite styles to tattoo?**
|
||||||
|
|
||||||
|
1. American and Japanese Traditional
|
||||||
|
2. Floral Black and Grey
|
||||||
|
3. Color Work
|
||||||
|
|
||||||
|
**What types of designs do you most enjoy creating?**
|
||||||
|
|
||||||
|
**Anything American Traditional**
|
||||||
|
|
||||||
|
**Are there any styles or subjects you prefer NOT to tattoo?**
|
||||||
|
|
||||||
|
**Realism**
|
||||||
|
|
||||||
|
## Portfolio Pieces
|
||||||
|
|
||||||
|
**Please list 5-10 of your best tattoos that represent your work:**
|
||||||
|
|
||||||
|
[https://portal.united-tattoos.com/index.php/f/17904](https://portal.united-tattoos.com/index.php/f/17904 (preview))
|
||||||
|
|
||||||
|
## Process & Approach
|
||||||
|
|
||||||
|
**Describe your consultation process with clients:**
|
||||||
|
|
||||||
|
**Talking about the design seeing the space they want it and then going over availability, price ranges and the scheduling with a deposit**
|
||||||
|
|
||||||
|
**How do you approach custom design work?**
|
||||||
|
|
||||||
|
**with love**
|
||||||
|
|
||||||
|
## Availability & Pricing
|
||||||
|
|
||||||
|
**Current booking status:**
|
||||||
|
|
||||||
|
- \[ x\] Currently booking
|
||||||
|
- \[ \] Waitlist
|
||||||
|
- \[ \] By appointment only
|
||||||
|
- \[x \] Walk-ins welcome
|
||||||
|
|
||||||
|
**Typical booking lead time:**
|
||||||
|
|
||||||
|
**idk what this means**
|
||||||
|
|
||||||
|
**Average session length:**
|
||||||
|
|
||||||
|
**depends on the tattoo**
|
||||||
|
|
||||||
|
**Hourly rate or price range:**
|
||||||
|
|
||||||
|
**I price by piece outside of day sessions**
|
||||||
|
|
||||||
|
**Minimum charge:**
|
||||||
|
|
||||||
|
**0**
|
||||||
|
|
||||||
|
**Do you require a deposit?** If yes, how much? yes depending on how much the tattoo is no more than $100 though
|
||||||
BIN
public/artists/Amari-Rodriguez/Flash/Arrow_Lady.jpg
Normal file
|
After Width: | Height: | Size: 516 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Ball_and_Chain_Face.jpg
Normal file
|
After Width: | Height: | Size: 373 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Beetle.jpg
Normal file
|
After Width: | Height: | Size: 517 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Bonsai.jpg
Normal file
|
After Width: | Height: | Size: 540 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Boys_Will_Be_Bugs_Print.jpg
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
public/artists/Amari-Rodriguez/Flash/Cactus.jpg
Normal file
|
After Width: | Height: | Size: 426 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Cowboy_Killer_Print.jpg
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
public/artists/Amari-Rodriguez/Flash/Dark_Horse.jpg
Normal file
|
After Width: | Height: | Size: 564 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Dragon_Castle.jpg
Normal file
|
After Width: | Height: | Size: 978 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Dune_Lady.jpg
Normal file
|
After Width: | Height: | Size: 421 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Flor_De_Femme.jpg
Normal file
|
After Width: | Height: | Size: 552 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Gypsy.jpg
Normal file
|
After Width: | Height: | Size: 497 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Heart_Dagger.jpg
Normal file
|
After Width: | Height: | Size: 515 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/JestersPrivilege_Print.jpg
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
public/artists/Amari-Rodriguez/Flash/Jesters_Privillege.jpg
Normal file
|
After Width: | Height: | Size: 405 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/LadyBug.jpg
Normal file
|
After Width: | Height: | Size: 203 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Lightnin_Bugz.jpg
Normal file
|
After Width: | Height: | Size: 394 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Monstera.jpg
Normal file
|
After Width: | Height: | Size: 372 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Outlaw.jpg
Normal file
|
After Width: | Height: | Size: 533 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Panther.jpg
Normal file
|
After Width: | Height: | Size: 635 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Queen.jpg
Normal file
|
After Width: | Height: | Size: 541 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Rosebush.jpg
Normal file
|
After Width: | Height: | Size: 467 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Sailor_Jerry.jpg
Normal file
|
After Width: | Height: | Size: 779 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Scorpion.jpg
Normal file
|
After Width: | Height: | Size: 333 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Skeleton_Cowboy.jpg
Normal file
|
After Width: | Height: | Size: 559 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Spread_Eagle.jpg
Normal file
|
After Width: | Height: | Size: 583 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/Traditional_Spiderweb.jpg
Normal file
|
After Width: | Height: | Size: 538 KiB |
BIN
public/artists/Amari-Rodriguez/Flash/_Caterpiller.jpg
Normal file
|
After Width: | Height: | Size: 344 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Alina Rose.HEIC
Normal file
BIN
public/artists/Amari-Rodriguez/Portfolio/Alina Rose.avif
Normal file
|
After Width: | Height: | Size: 287 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Alina Russian Doll.avif
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Alina Russian Doll.jpg
Normal file
|
After Width: | Height: | Size: 166 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Alina Seppuku.HEIC
Normal file
BIN
public/artists/Amari-Rodriguez/Portfolio/Alina Seppuku.avif
Normal file
|
After Width: | Height: | Size: 391 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Alina Sombrero.avif
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Alina Sombrero.jpg
Normal file
|
After Width: | Height: | Size: 172 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Anahi Sternum.HEIC
Normal file
BIN
public/artists/Amari-Rodriguez/Portfolio/Anahi Sternum.avif
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Anna Clown.avif
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Anna Clown.jpg
Normal file
|
After Width: | Height: | Size: 176 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Anna Cowgirl.avif
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Anna Cowgirl.jpg
Normal file
|
After Width: | Height: | Size: 155 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Ash Hummingbird.avif
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Ash Hummingbird.jpg
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Cam Bonsai.HEIC
Normal file
BIN
public/artists/Amari-Rodriguez/Portfolio/Cam Bonsai.avif
Normal file
|
After Width: | Height: | Size: 739 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Evaline Guillotine.HEIC
Normal file
BIN
public/artists/Amari-Rodriguez/Portfolio/Evaline Guillotine.avif
Normal file
|
After Width: | Height: | Size: 391 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Evaline Vamp.avif
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Evaline Vamp.jpg
Normal file
|
After Width: | Height: | Size: 173 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Hailey Raven Skull.avif
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Hailey Raven Skull.jpg
Normal file
|
After Width: | Height: | Size: 183 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Ian Bat.avif
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Ian Bat.jpg
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Isabel Crane.avif
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Isabel Crane.jpg
Normal file
|
After Width: | Height: | Size: 156 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Jazzy Hand.avif
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Jazzy Hand.jpg
Normal file
|
After Width: | Height: | Size: 161 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Jazzy Zombie.avif
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/artists/Amari-Rodriguez/Portfolio/Jazzy Zombie.jpg
Normal file
|
After Width: | Height: | Size: 139 KiB |