From 4514fe4faa6720a6dd7d51aa6366816decf29f7c Mon Sep 17 00:00:00 2001
From: anshuman-krishna
Date: Tue, 28 Apr 2026 01:05:28 +0530
Subject: [PATCH] security lockdown pass, depth, prod dep prep
---
prisma/schema.prisma | 92 ++++++++
public/sw.js | 48 ++++
src/app/api/admin/users/route.ts | 4 +-
.../api/artists/[artistId]/similar/route.ts | 23 ++
.../api/artists/[artistId]/style-dna/route.ts | 71 ++++++
src/app/api/cron/cleanup/route.ts | 60 +++++
src/app/api/cron/style-dna/route.ts | 16 ++
src/app/api/events/[slug]/ics/route.ts | 33 +++
src/app/api/events/[slug]/rsvp/route.ts | 57 +++++
src/app/api/events/route.ts | 47 ++++
src/app/api/link-preview/route.ts | 34 +++
src/app/api/longevity/route.ts | 44 ++++
src/app/api/push/subscribe/route.ts | 51 +++++
src/app/api/push/unsubscribe/route.ts | 24 ++
src/app/api/shops/map/route.ts | 23 ++
src/app/api/socket/token/route.ts | 20 ++
src/app/app/ar-preview/page.tsx | 8 +
src/app/app/artist/[artistSlug]/page.tsx | 18 +-
src/app/app/events/[slug]/page.tsx | 82 +++++++
src/app/app/events/page.tsx | 67 ++++++
src/app/app/learn/[slug]/page.tsx | 48 ++++
src/app/app/learn/page.tsx | 48 ++++
src/app/app/longevity/page.tsx | 40 ++++
src/app/app/map/page.tsx | 35 +++
src/backend/realtime/socket-server.ts | 22 +-
src/backend/services/event-service.ts | 106 +++++++++
src/backend/services/learn-service.ts | 91 ++++++++
src/backend/services/link-preview-service.ts | 152 +++++++++++++
src/backend/services/longevity-service.ts | 117 ++++++++++
src/backend/services/notification-service.ts | 26 ++-
src/backend/services/shop-service.ts | 22 ++
src/backend/services/style-dna-service.ts | 158 +++++++++++++
src/backend/services/user-service.ts | 22 +-
src/components/features/ai/ai-prompt-form.tsx | 16 ++
.../features/artists/style-dna-chart.tsx | 66 ++++++
.../features/booking/booking-form.tsx | 7 +
.../features/events/event-rsvp-controls.tsx | 70 ++++++
.../longevity/longevity-simulator.tsx | 208 ++++++++++++++++++
src/components/features/map/shops-map.tsx | 165 ++++++++++++++
.../notifications/web-push-toggle.tsx | 111 ++++++++++
.../pricing/price-estimate-widget.tsx | 106 +++++++++
.../features/social/link-preview-card.tsx | 73 ++++++
src/content/learn/aftercare-basics.md | 32 +++
src/content/learn/before-your-first-tattoo.md | 37 ++++
src/content/learn/choosing-a-style.md | 44 ++++
src/hooks/use-socket.ts | 44 +++-
src/lib/cron.ts | 17 ++
src/lib/env.ts | 30 +++
src/lib/feature-flags.ts | 7 +
src/lib/session-rotate.ts | 8 +
src/lib/socket-jwt.ts | 67 ++++++
src/lib/ssrf.ts | 147 +++++++++++++
src/lib/validations.ts | 53 +++++
src/lib/web-push.ts | 132 +++++++++++
src/proxy.ts | 11 +-
tests/services/event-service.test.ts | 35 +++
tests/services/longevity-service.test.ts | 34 +++
tests/services/style-dna-service.test.ts | 60 +++++
.../__snapshots__/api-contract.test.ts.snap | 55 +++++
tests/unit/lib/feature-flags.test.ts | 7 +
tests/unit/lib/socket-jwt.test.ts | 36 +++
tests/unit/lib/ssrf.test.ts | 43 ++++
vercel.json | 13 ++
63 files changed, 3418 insertions(+), 25 deletions(-)
create mode 100644 public/sw.js
create mode 100644 src/app/api/artists/[artistId]/similar/route.ts
create mode 100644 src/app/api/artists/[artistId]/style-dna/route.ts
create mode 100644 src/app/api/cron/cleanup/route.ts
create mode 100644 src/app/api/cron/style-dna/route.ts
create mode 100644 src/app/api/events/[slug]/ics/route.ts
create mode 100644 src/app/api/events/[slug]/rsvp/route.ts
create mode 100644 src/app/api/events/route.ts
create mode 100644 src/app/api/link-preview/route.ts
create mode 100644 src/app/api/longevity/route.ts
create mode 100644 src/app/api/push/subscribe/route.ts
create mode 100644 src/app/api/push/unsubscribe/route.ts
create mode 100644 src/app/api/shops/map/route.ts
create mode 100644 src/app/api/socket/token/route.ts
create mode 100644 src/app/app/events/[slug]/page.tsx
create mode 100644 src/app/app/events/page.tsx
create mode 100644 src/app/app/learn/[slug]/page.tsx
create mode 100644 src/app/app/learn/page.tsx
create mode 100644 src/app/app/longevity/page.tsx
create mode 100644 src/app/app/map/page.tsx
create mode 100644 src/backend/services/event-service.ts
create mode 100644 src/backend/services/learn-service.ts
create mode 100644 src/backend/services/link-preview-service.ts
create mode 100644 src/backend/services/longevity-service.ts
create mode 100644 src/backend/services/style-dna-service.ts
create mode 100644 src/components/features/artists/style-dna-chart.tsx
create mode 100644 src/components/features/events/event-rsvp-controls.tsx
create mode 100644 src/components/features/longevity/longevity-simulator.tsx
create mode 100644 src/components/features/map/shops-map.tsx
create mode 100644 src/components/features/notifications/web-push-toggle.tsx
create mode 100644 src/components/features/pricing/price-estimate-widget.tsx
create mode 100644 src/components/features/social/link-preview-card.tsx
create mode 100644 src/content/learn/aftercare-basics.md
create mode 100644 src/content/learn/before-your-first-tattoo.md
create mode 100644 src/content/learn/choosing-a-style.md
create mode 100644 src/lib/cron.ts
create mode 100644 src/lib/session-rotate.ts
create mode 100644 src/lib/socket-jwt.ts
create mode 100644 src/lib/ssrf.ts
create mode 100644 src/lib/web-push.ts
create mode 100644 tests/services/event-service.test.ts
create mode 100644 tests/services/longevity-service.test.ts
create mode 100644 tests/services/style-dna-service.test.ts
create mode 100644 tests/unit/lib/socket-jwt.test.ts
create mode 100644 tests/unit/lib/ssrf.test.ts
create mode 100644 vercel.json
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 6a7114c..7cbdf4a 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -125,6 +125,7 @@ model Artist {
reviews Review[]
availability ArtistAvailability[]
shopArtists ShopArtist[]
+ styleDna ArtistStyleDna?
@@index([userId])
@@index([slug])
@@ -133,6 +134,24 @@ model Artist {
@@index([verified])
}
+// per-artist style fingerprint, recomputed weekly from portfolio tattoos
+model ArtistStyleDna {
+ id String @id @default(cuid())
+ artistId String @unique
+ // style weights as { TRADITIONAL: 0.6, JAPANESE: 0.25, ... } summing to ~1
+ weights Json
+ // optional aggregate embedding vector for similarity search.
+ // stored as float[] until pgvector migration lands.
+ embedding Float[]
+ sampleSize Int @default(0)
+ computedAt DateTime @default(now())
+
+ artist Artist @relation(fields: [artistId], references: [id], onDelete: Cascade)
+
+ @@index([artistId])
+ @@index([computedAt])
+}
+
model Shop {
id String @id @default(cuid())
name String
@@ -677,3 +696,76 @@ model CollectionItem {
@@index([tattooId])
@@index([postId])
}
+
+// tattoo conventions / events
+model Event {
+ id String @id @default(cuid())
+ name String
+ slug String @unique
+ description String? @db.Text
+ venue String?
+ city String?
+ country String?
+ latitude Float?
+ longitude Float?
+ startsAt DateTime
+ endsAt DateTime
+ websiteUrl String?
+ coverImage String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ attendees EventAttendance[]
+
+ @@index([slug])
+ @@index([startsAt])
+ @@index([city])
+}
+
+model EventAttendance {
+ id String @id @default(cuid())
+ eventId String
+ userId String
+ status EventAttendanceStatus @default(GOING)
+ createdAt DateTime @default(now())
+
+ event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
+
+ @@unique([eventId, userId])
+ @@index([eventId])
+ @@index([userId])
+}
+
+enum EventAttendanceStatus {
+ GOING
+ INTERESTED
+ NOT_GOING
+}
+
+// web push subscriptions (vapid)
+model PushSubscription {
+ id String @id @default(cuid())
+ userId String
+ endpoint String @unique
+ p256dh String
+ authKey String
+ userAgent String?
+ createdAt DateTime @default(now())
+
+ @@index([userId])
+}
+
+// cached og/oembed link previews (rich previews on posts)
+model LinkPreview {
+ id String @id @default(cuid())
+ url String @unique
+ canonicalUrl String?
+ title String?
+ description String?
+ image String?
+ siteName String?
+ fetchedAt DateTime @default(now())
+ expiresAt DateTime
+
+ @@index([expiresAt])
+}
diff --git a/public/sw.js b/public/sw.js
new file mode 100644
index 0000000..5866f36
--- /dev/null
+++ b/public/sw.js
@@ -0,0 +1,48 @@
+// AETCH service worker for web push notifications.
+// keep this small + dependency-free so first-load cost is minimal.
+
+self.addEventListener('install', () => {
+ self.skipWaiting();
+});
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(self.clients.claim());
+});
+
+self.addEventListener('push', (event) => {
+ let payload = { title: 'AETCH', body: 'New activity', url: '/app/notifications' };
+ try {
+ if (event.data) {
+ const text = event.data.text();
+ if (text) payload = { ...payload, ...JSON.parse(text) };
+ }
+ } catch {
+ // empty payload (tickle) — fall through to default copy
+ }
+ event.waitUntil(
+ self.registration.showNotification(payload.title, {
+ body: payload.body,
+ data: { url: payload.url },
+ badge: '/icon.png',
+ icon: '/icon.png',
+ }),
+ );
+});
+
+self.addEventListener('notificationclick', (event) => {
+ event.notification.close();
+ const url = (event.notification.data && event.notification.data.url) || '/app/notifications';
+ event.waitUntil(
+ self.clients
+ .matchAll({ type: 'window', includeUncontrolled: true })
+ .then((clientList) => {
+ for (const client of clientList) {
+ if ('focus' in client) {
+ client.navigate(url);
+ return client.focus();
+ }
+ }
+ return self.clients.openWindow(url);
+ }),
+ );
+});
diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts
index 7b4f5e7..c179ac9 100644
--- a/src/app/api/admin/users/route.ts
+++ b/src/app/api/admin/users/route.ts
@@ -8,6 +8,7 @@ import { prisma } from '@/lib/prisma';
import { paginationSchema } from '@/lib/validations';
import { getPaginationParams, buildPaginationMeta } from '@/utils/pagination';
import { recordAuditEvent, clientContext } from '@/backend/services/audit-log-service';
+import { rotateUserSessions } from '@/lib/session-rotate';
// list users (admin only)
export const GET = withErrorHandler(async (req: Request) => {
@@ -69,12 +70,13 @@ export const PATCH = withErrorHandler(async (req: Request) => {
);
}
- // remove all roles to disable
+ // remove all roles to disable + rotate sessions so the user is forced to re-auth
if (disabled) {
await prisma.user.update({
where: { id: userId },
data: { roles: [] },
});
+ await rotateUserSessions(userId);
}
await recordAuditEvent({
diff --git a/src/app/api/artists/[artistId]/similar/route.ts b/src/app/api/artists/[artistId]/similar/route.ts
new file mode 100644
index 0000000..ca7ae53
--- /dev/null
+++ b/src/app/api/artists/[artistId]/similar/route.ts
@@ -0,0 +1,23 @@
+export const runtime = 'nodejs';
+
+import { NextResponse } from 'next/server';
+import { withErrorHandler } from '@/lib/api-error';
+import { withRequestId } from '@/backend/middleware/request-log';
+import { isFeatureEnabled } from '@/lib/feature-flags';
+import { findSimilarArtists } from '@/backend/services/style-dna-service';
+
+interface Ctx {
+ params: Promise<{ artistId: string }>;
+}
+
+export const GET = withErrorHandler(async (req: Request, ctx: unknown) => {
+ if (!isFeatureEnabled('STYLE_DNA_ENABLED')) {
+ return withRequestId(req, NextResponse.json({ success: true, similar: [] }));
+ }
+ const { params } = ctx as Ctx;
+ const { artistId } = await params;
+ const url = new URL(req.url);
+ const limit = Math.min(20, Math.max(1, Number(url.searchParams.get('limit') ?? 8)));
+ const similar = await findSimilarArtists(artistId, limit);
+ return withRequestId(req, NextResponse.json({ success: true, similar }));
+}, 'GET /api/artists/[artistId]/similar');
diff --git a/src/app/api/artists/[artistId]/style-dna/route.ts b/src/app/api/artists/[artistId]/style-dna/route.ts
new file mode 100644
index 0000000..b73d8ce
--- /dev/null
+++ b/src/app/api/artists/[artistId]/style-dna/route.ts
@@ -0,0 +1,71 @@
+export const runtime = 'nodejs';
+
+import { NextResponse } from 'next/server';
+import { withErrorHandler } from '@/lib/api-error';
+import { withRequestId } from '@/backend/middleware/request-log';
+import { isFeatureEnabled } from '@/lib/feature-flags';
+import {
+ getStyleDna,
+ computeArtistStyleDna,
+ persistStyleDna,
+} from '@/backend/services/style-dna-service';
+import { authGuard } from '@/backend/middleware/auth-guard';
+import { prisma } from '@/lib/prisma';
+
+interface Ctx {
+ params: Promise<{ artistId: string }>;
+}
+
+export const GET = withErrorHandler(async (req: Request, ctx: unknown) => {
+ if (!isFeatureEnabled('STYLE_DNA_ENABLED')) {
+ return withRequestId(req, NextResponse.json({ success: true, dna: null }));
+ }
+ const { params } = ctx as Ctx;
+ const { artistId } = await params;
+ const dna = await getStyleDna(artistId);
+ return withRequestId(req, NextResponse.json({ success: true, dna }));
+}, 'GET /api/artists/[artistId]/style-dna');
+
+// owner-triggered recompute
+export const POST = withErrorHandler(async (req: Request, ctx: unknown) => {
+ if (!isFeatureEnabled('STYLE_DNA_ENABLED')) {
+ return withRequestId(
+ req,
+ NextResponse.json({ success: false, error: 'Style DNA disabled' }, { status: 403 }),
+ );
+ }
+ const { session, error } = await authGuard();
+ if (error) return withRequestId(req, error);
+
+ const { params } = ctx as Ctx;
+ const { artistId } = await params;
+ const artist = await prisma.artist.findUnique({
+ where: { id: artistId },
+ select: { userId: true },
+ });
+ if (!artist) {
+ return withRequestId(
+ req,
+ NextResponse.json({ success: false, error: 'Artist not found' }, { status: 404 }),
+ );
+ }
+ if (artist.userId !== session.user.id && !session.user.roles.includes('ADMIN')) {
+ return withRequestId(
+ req,
+ NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 }),
+ );
+ }
+
+ const result = await computeArtistStyleDna(artistId);
+ if (result.sampleSize === 0) {
+ return withRequestId(
+ req,
+ NextResponse.json(
+ { success: false, error: 'Need at least one tattoo with a style' },
+ { status: 422 },
+ ),
+ );
+ }
+ await persistStyleDna(result);
+ return withRequestId(req, NextResponse.json({ success: true, dna: result }));
+}, 'POST /api/artists/[artistId]/style-dna');
diff --git a/src/app/api/cron/cleanup/route.ts b/src/app/api/cron/cleanup/route.ts
new file mode 100644
index 0000000..9724064
--- /dev/null
+++ b/src/app/api/cron/cleanup/route.ts
@@ -0,0 +1,60 @@
+export const runtime = 'nodejs';
+
+import { NextResponse } from 'next/server';
+import { withErrorHandler } from '@/lib/api-error';
+import { withRequestId } from '@/backend/middleware/request-log';
+import { assertCronAuth } from '@/lib/cron';
+import { prisma } from '@/lib/prisma';
+import { logger } from '@/lib/logger';
+
+const NOTIFICATION_TTL_DAYS = 60;
+const PENDING_BOOKING_TTL_DAYS = 14;
+const AI_GENERATION_TTL_DAYS = 30;
+
+// vercel cron + manual trigger; runs at 03:00 utc daily
+export const GET = withErrorHandler(async (req: Request) => {
+ const unauth = assertCronAuth(req);
+ if (unauth) return withRequestId(req, unauth);
+
+ const now = Date.now();
+ const notifCutoff = new Date(now - NOTIFICATION_TTL_DAYS * 24 * 60 * 60 * 1000);
+ const bookingCutoff = new Date(now - PENDING_BOOKING_TTL_DAYS * 24 * 60 * 60 * 1000);
+ const aiCutoff = new Date(now - AI_GENERATION_TTL_DAYS * 24 * 60 * 60 * 1000);
+
+ const [staleNotifications, expiredBookings, staleAi] = await Promise.all([
+ prisma.notification.deleteMany({
+ where: { read: true, createdAt: { lt: notifCutoff } },
+ }),
+ prisma.booking.updateMany({
+ where: {
+ status: 'PENDING',
+ date: { lt: bookingCutoff },
+ },
+ data: { status: 'CANCELLED' },
+ }),
+ prisma.aIGeneration.deleteMany({
+ where: { status: 'FAILED', createdAt: { lt: aiCutoff } },
+ }),
+ ]);
+
+ logger.info(
+ {
+ staleNotifications: staleNotifications.count,
+ expiredBookings: expiredBookings.count,
+ staleAi: staleAi.count,
+ },
+ 'cron cleanup completed',
+ );
+
+ return withRequestId(
+ req,
+ NextResponse.json({
+ success: true,
+ cleaned: {
+ notifications: staleNotifications.count,
+ bookings: expiredBookings.count,
+ aiGenerations: staleAi.count,
+ },
+ }),
+ );
+}, 'GET /api/cron/cleanup');
diff --git a/src/app/api/cron/style-dna/route.ts b/src/app/api/cron/style-dna/route.ts
new file mode 100644
index 0000000..7052e79
--- /dev/null
+++ b/src/app/api/cron/style-dna/route.ts
@@ -0,0 +1,16 @@
+export const runtime = 'nodejs';
+
+import { NextResponse } from 'next/server';
+import { withErrorHandler } from '@/lib/api-error';
+import { withRequestId } from '@/backend/middleware/request-log';
+import { assertCronAuth } from '@/lib/cron';
+import { recomputeAllStyleDna } from '@/backend/services/style-dna-service';
+
+// vercel cron: weekly style-dna recompute (sundays 04:00 utc)
+export const GET = withErrorHandler(async (req: Request) => {
+ const unauth = assertCronAuth(req);
+ if (unauth) return withRequestId(req, unauth);
+
+ const result = await recomputeAllStyleDna();
+ return withRequestId(req, NextResponse.json({ success: true, ...result }));
+}, 'GET /api/cron/style-dna');
diff --git a/src/app/api/events/[slug]/ics/route.ts b/src/app/api/events/[slug]/ics/route.ts
new file mode 100644
index 0000000..a6013cd
--- /dev/null
+++ b/src/app/api/events/[slug]/ics/route.ts
@@ -0,0 +1,33 @@
+export const runtime = 'nodejs';
+
+import { NextResponse } from 'next/server';
+import { withErrorHandler } from '@/lib/api-error';
+import { withRequestId } from '@/backend/middleware/request-log';
+import { getEventBySlug, eventToIcs } from '@/backend/services/event-service';
+
+interface Ctx {
+ params: Promise<{ slug: string }>;
+}
+
+export const GET = withErrorHandler(async (req: Request, ctx: unknown) => {
+ const { params } = ctx as Ctx;
+ const { slug } = await params;
+ const event = await getEventBySlug(slug);
+ if (!event) {
+ return withRequestId(
+ req,
+ NextResponse.json({ success: false, error: 'Event not found' }, { status: 404 }),
+ );
+ }
+ const ics = eventToIcs(event);
+ return withRequestId(
+ req,
+ new NextResponse(ics, {
+ status: 200,
+ headers: {
+ 'content-type': 'text/calendar; charset=utf-8',
+ 'content-disposition': `attachment; filename="${event.slug}.ics"`,
+ },
+ }),
+ );
+}, 'GET /api/events/[slug]/ics');
diff --git a/src/app/api/events/[slug]/rsvp/route.ts b/src/app/api/events/[slug]/rsvp/route.ts
new file mode 100644
index 0000000..fe7ddb3
--- /dev/null
+++ b/src/app/api/events/[slug]/rsvp/route.ts
@@ -0,0 +1,57 @@
+export const runtime = 'nodejs';
+
+import { NextResponse } from 'next/server';
+import { authGuard } from '@/backend/middleware/auth-guard';
+import { withErrorHandler } from '@/lib/api-error';
+import { withRequestId } from '@/backend/middleware/request-log';
+import { eventRsvpSchema } from '@/lib/validations';
+import { rsvpToEvent, cancelRsvp, getEventBySlug } from '@/backend/services/event-service';
+
+interface Ctx {
+ params: Promise<{ slug: string }>;
+}
+
+export const POST = withErrorHandler(async (req: Request, ctx: unknown) => {
+ const { session, error } = await authGuard();
+ if (error) return withRequestId(req, error);
+
+ const { params } = ctx as Ctx;
+ const { slug } = await params;
+ const event = await getEventBySlug(slug);
+ if (!event) {
+ return withRequestId(
+ req,
+ NextResponse.json({ success: false, error: 'Event not found' }, { status: 404 }),
+ );
+ }
+ const body = await req.json();
+ const parsed = eventRsvpSchema.safeParse(body);
+ if (!parsed.success) {
+ return withRequestId(
+ req,
+ NextResponse.json(
+ { success: false, error: parsed.error.issues[0]?.message ?? 'Invalid status' },
+ { status: 400 },
+ ),
+ );
+ }
+ const rsvp = await rsvpToEvent(event.id, session.user.id, parsed.data.status);
+ return withRequestId(req, NextResponse.json({ success: true, rsvp }));
+}, 'POST /api/events/[slug]/rsvp');
+
+export const DELETE = withErrorHandler(async (req: Request, ctx: unknown) => {
+ const { session, error } = await authGuard();
+ if (error) return withRequestId(req, error);
+
+ const { params } = ctx as Ctx;
+ const { slug } = await params;
+ const event = await getEventBySlug(slug);
+ if (!event) {
+ return withRequestId(
+ req,
+ NextResponse.json({ success: false, error: 'Event not found' }, { status: 404 }),
+ );
+ }
+ await cancelRsvp(event.id, session.user.id);
+ return withRequestId(req, NextResponse.json({ success: true }));
+}, 'DELETE /api/events/[slug]/rsvp');
diff --git a/src/app/api/events/route.ts b/src/app/api/events/route.ts
new file mode 100644
index 0000000..e6fe401
--- /dev/null
+++ b/src/app/api/events/route.ts
@@ -0,0 +1,47 @@
+export const runtime = 'nodejs';
+
+import { NextResponse } from 'next/server';
+import { withErrorHandler } from '@/lib/api-error';
+import { withRequestId } from '@/backend/middleware/request-log';
+import { requireRole } from '@/backend/middleware/role-guard';
+import { createEventSchema, paginationSchema } from '@/lib/validations';
+import { createEvent, listUpcomingEvents } from '@/backend/services/event-service';
+import { getPaginationParams } from '@/utils/pagination';
+
+export const GET = withErrorHandler(async (req: Request) => {
+ const { searchParams } = new URL(req.url);
+ const parsed = paginationSchema.safeParse({
+ page: searchParams.get('page'),
+ limit: searchParams.get('limit'),
+ });
+ const pagination = getPaginationParams(
+ parsed.success ? parsed.data.page : 1,
+ parsed.success ? parsed.data.limit : 20,
+ );
+ const data = await listUpcomingEvents(pagination);
+ return withRequestId(req, NextResponse.json({ success: true, ...data }));
+}, 'GET /api/events');
+
+// admin-only event creation for now — public-facing convention listings later
+export const POST = withErrorHandler(async (req: Request) => {
+ const { error } = await requireRole('ADMIN');
+ if (error) return withRequestId(req, error);
+
+ const body = await req.json();
+ const parsed = createEventSchema.safeParse(body);
+ if (!parsed.success) {
+ return withRequestId(
+ req,
+ NextResponse.json(
+ { success: false, error: parsed.error.issues[0]?.message ?? 'Invalid input' },
+ { status: 400 },
+ ),
+ );
+ }
+ const event = await createEvent({
+ ...parsed.data,
+ startsAt: new Date(parsed.data.startsAt),
+ endsAt: new Date(parsed.data.endsAt),
+ });
+ return withRequestId(req, NextResponse.json({ success: true, event }, { status: 201 }));
+}, 'POST /api/events');
diff --git a/src/app/api/link-preview/route.ts b/src/app/api/link-preview/route.ts
new file mode 100644
index 0000000..96610cd
--- /dev/null
+++ b/src/app/api/link-preview/route.ts
@@ -0,0 +1,34 @@
+export const runtime = 'nodejs';
+
+import { NextResponse } from 'next/server';
+import { authGuard } from '@/backend/middleware/auth-guard';
+import { withErrorHandler } from '@/lib/api-error';
+import { withRequestId } from '@/backend/middleware/request-log';
+import { rateLimit } from '@/backend/middleware/rate-limit';
+import { linkPreviewSchema } from '@/lib/validations';
+import { getLinkPreview } from '@/backend/services/link-preview-service';
+
+export const GET = withErrorHandler(async (req: Request) => {
+ const { session, error } = await authGuard();
+ if (error) return withRequestId(req, error);
+
+ const rl = await rateLimit(session.user.id, 'api');
+ if (!rl.success) return withRequestId(req, rl.error);
+
+ const url = new URL(req.url).searchParams.get('url');
+ const parsed = linkPreviewSchema.safeParse({ url });
+ if (!parsed.success) {
+ return withRequestId(
+ req,
+ NextResponse.json(
+ { success: false, error: parsed.error.issues[0]?.message ?? 'Invalid url' },
+ { status: 400 },
+ ),
+ );
+ }
+ const preview = await getLinkPreview(parsed.data.url);
+ if (!preview) {
+ return withRequestId(req, NextResponse.json({ success: true, preview: null }));
+ }
+ return withRequestId(req, NextResponse.json({ success: true, preview }));
+}, 'GET /api/link-preview');
diff --git a/src/app/api/longevity/route.ts b/src/app/api/longevity/route.ts
new file mode 100644
index 0000000..9ee3a9a
--- /dev/null
+++ b/src/app/api/longevity/route.ts
@@ -0,0 +1,44 @@
+export const runtime = 'nodejs';
+
+import { NextResponse } from 'next/server';
+import { authGuard } from '@/backend/middleware/auth-guard';
+import { withErrorHandler } from '@/lib/api-error';
+import { withRequestId } from '@/backend/middleware/request-log';
+import { isFeatureEnabled } from '@/lib/feature-flags';
+import { longevitySchema } from '@/lib/validations';
+import { simulateAgingTimeline } from '@/backend/services/longevity-service';
+
+export const POST = withErrorHandler(async (req: Request) => {
+ if (!isFeatureEnabled('LONGEVITY_ENABLED')) {
+ return withRequestId(
+ req,
+ NextResponse.json({ success: false, error: 'Longevity disabled' }, { status: 403 }),
+ );
+ }
+ const { error } = await authGuard();
+ if (error) return withRequestId(req, error);
+
+ const body = await req.json();
+ const parsed = longevitySchema.safeParse(body);
+ if (!parsed.success) {
+ return withRequestId(
+ req,
+ NextResponse.json(
+ { success: false, error: parsed.error.issues[0]?.message ?? 'Invalid input' },
+ { status: 400 },
+ ),
+ );
+ }
+
+ const timeline = simulateAgingTimeline({
+ lineThickness: parsed.data.lineThickness,
+ colorPalette: parsed.data.colorPalette,
+ placement: parsed.data.placement,
+ style: parsed.data.style,
+ });
+
+ return withRequestId(
+ req,
+ NextResponse.json({ success: true, imageUrl: parsed.data.imageUrl, timeline }),
+ );
+}, 'POST /api/longevity');
diff --git a/src/app/api/push/subscribe/route.ts b/src/app/api/push/subscribe/route.ts
new file mode 100644
index 0000000..0643ce4
--- /dev/null
+++ b/src/app/api/push/subscribe/route.ts
@@ -0,0 +1,51 @@
+export const runtime = 'nodejs';
+
+import { NextResponse } from 'next/server';
+import { authGuard } from '@/backend/middleware/auth-guard';
+import { withErrorHandler } from '@/lib/api-error';
+import { withRequestId } from '@/backend/middleware/request-log';
+import { isFeatureEnabled } from '@/lib/feature-flags';
+import { pushSubscribeSchema } from '@/lib/validations';
+import { prisma } from '@/lib/prisma';
+
+export const POST = withErrorHandler(async (req: Request) => {
+ if (!isFeatureEnabled('WEB_PUSH_ENABLED')) {
+ return withRequestId(
+ req,
+ NextResponse.json({ success: false, error: 'Web push disabled' }, { status: 403 }),
+ );
+ }
+ const { session, error } = await authGuard();
+ if (error) return withRequestId(req, error);
+
+ const body = await req.json();
+ const parsed = pushSubscribeSchema.safeParse(body);
+ if (!parsed.success) {
+ return withRequestId(
+ req,
+ NextResponse.json(
+ { success: false, error: parsed.error.issues[0]?.message ?? 'Invalid input' },
+ { status: 400 },
+ ),
+ );
+ }
+
+ await prisma.pushSubscription.upsert({
+ where: { endpoint: parsed.data.endpoint },
+ create: {
+ userId: session.user.id,
+ endpoint: parsed.data.endpoint,
+ p256dh: parsed.data.keys.p256dh,
+ authKey: parsed.data.keys.auth,
+ userAgent: parsed.data.userAgent,
+ },
+ update: {
+ userId: session.user.id,
+ p256dh: parsed.data.keys.p256dh,
+ authKey: parsed.data.keys.auth,
+ userAgent: parsed.data.userAgent,
+ },
+ });
+
+ return withRequestId(req, NextResponse.json({ success: true }));
+}, 'POST /api/push/subscribe');
diff --git a/src/app/api/push/unsubscribe/route.ts b/src/app/api/push/unsubscribe/route.ts
new file mode 100644
index 0000000..2c508e0
--- /dev/null
+++ b/src/app/api/push/unsubscribe/route.ts
@@ -0,0 +1,24 @@
+export const runtime = 'nodejs';
+
+import { NextResponse } from 'next/server';
+import { authGuard } from '@/backend/middleware/auth-guard';
+import { withErrorHandler } from '@/lib/api-error';
+import { withRequestId } from '@/backend/middleware/request-log';
+import { prisma } from '@/lib/prisma';
+
+export const POST = withErrorHandler(async (req: Request) => {
+ const { session, error } = await authGuard();
+ if (error) return withRequestId(req, error);
+
+ const { endpoint } = await req.json();
+ if (typeof endpoint !== 'string') {
+ return withRequestId(
+ req,
+ NextResponse.json({ success: false, error: 'endpoint required' }, { status: 400 }),
+ );
+ }
+ await prisma.pushSubscription.deleteMany({
+ where: { endpoint, userId: session.user.id },
+ });
+ return withRequestId(req, NextResponse.json({ success: true }));
+}, 'POST /api/push/unsubscribe');
diff --git a/src/app/api/shops/map/route.ts b/src/app/api/shops/map/route.ts
new file mode 100644
index 0000000..1e590ad
--- /dev/null
+++ b/src/app/api/shops/map/route.ts
@@ -0,0 +1,23 @@
+export const runtime = 'nodejs';
+
+import { NextResponse } from 'next/server';
+import { withErrorHandler } from '@/lib/api-error';
+import { withRequestId } from '@/backend/middleware/request-log';
+import { listShopsForMap } from '@/backend/services/shop-service';
+
+export const GET = withErrorHandler(async (req: Request) => {
+ const shops = await listShopsForMap();
+ // re-shape into geojson-ish features so map clients can render directly
+ const features = shops.map((s) => ({
+ id: s.id,
+ slug: s.slug,
+ name: s.name,
+ city: s.city,
+ country: s.country,
+ image: s.image,
+ verified: s.verified,
+ lng: s.longitude,
+ lat: s.latitude,
+ }));
+ return withRequestId(req, NextResponse.json({ success: true, shops: features }));
+}, 'GET /api/shops/map');
diff --git a/src/app/api/socket/token/route.ts b/src/app/api/socket/token/route.ts
new file mode 100644
index 0000000..83a2f8a
--- /dev/null
+++ b/src/app/api/socket/token/route.ts
@@ -0,0 +1,20 @@
+export const runtime = 'nodejs';
+
+import { NextResponse } from 'next/server';
+import { authGuard } from '@/backend/middleware/auth-guard';
+import { withErrorHandler } from '@/lib/api-error';
+import { withRequestId } from '@/backend/middleware/request-log';
+import { mintSocketToken } from '@/lib/socket-jwt';
+
+// mint a short-lived bearer for the socket handshake.
+// client calls this on connect + on reconnect.
+export const GET = withErrorHandler(async (req: Request) => {
+ const { session, error } = await authGuard();
+ if (error) return withRequestId(req, error);
+
+ const token = mintSocketToken(session.user.id);
+ return withRequestId(
+ req,
+ NextResponse.json({ success: true, token, expiresIn: 60 }),
+ );
+}, 'GET /api/socket/token');
diff --git a/src/app/app/ar-preview/page.tsx b/src/app/app/ar-preview/page.tsx
index 8624754..c061d63 100644
--- a/src/app/app/ar-preview/page.tsx
+++ b/src/app/app/ar-preview/page.tsx
@@ -18,6 +18,7 @@ import { PreviewExportButton } from '@/components/features/ar/preview-export-but
import { History, ScanEye } from 'lucide-react';
import Link from 'next/link';
import { BODY_PLACEMENTS, PLACEMENT_LABELS } from '@/lib/validations';
+import { PriceEstimateWidget } from '@/components/features/pricing/price-estimate-widget';
const DEFAULT_TRANSFORM: PreviewTransform = {
positionX: 50,
@@ -158,6 +159,13 @@ export default function ARPreviewPage() {
{canPreview && (
)}
+
+ {placement && (
+ 1.5 ? 'LARGE' : transform.scale > 0.8 ? 'MEDIUM' : 'SMALL'}
+ placement={placement}
+ />
+ )}
diff --git a/src/app/app/artist/[artistSlug]/page.tsx b/src/app/app/artist/[artistSlug]/page.tsx
index 413b2d6..83842f8 100644
--- a/src/app/app/artist/[artistSlug]/page.tsx
+++ b/src/app/app/artist/[artistSlug]/page.tsx
@@ -1,6 +1,8 @@
import { notFound } from 'next/navigation';
import { getArtistBySlug } from '@/backend/services/artist-service';
import { getArtistTattoos } from '@/backend/services/tattoo-service';
+import { getStyleDna } from '@/backend/services/style-dna-service';
+import { isFeatureEnabled } from '@/lib/feature-flags';
import { getPaginationParams } from '@/utils/pagination';
import { GlassCard } from '@/components/ui/glass-card';
import { GlassAvatar } from '@/components/ui/glass-avatar';
@@ -9,6 +11,7 @@ import { GlassButton } from '@/components/ui/glass-button';
import { PageContainer } from '@/components/layouts/page-container';
import { TattooGrid } from '@/components/features/gallery/tattoo-grid';
import { Pagination } from '@/components/ui/pagination';
+import { StyleDnaChart } from '@/components/features/artists/style-dna-chart';
import Link from 'next/link';
interface Props {
@@ -34,7 +37,10 @@ export default async function ArtistProfilePage({ params, searchParams }: Props)
const page = Number(resolvedSearchParams.page ?? '1');
const pagination = getPaginationParams(page, 12);
- const portfolio = await getArtistTattoos(artist.id, pagination);
+ const [portfolio, dna] = await Promise.all([
+ getArtistTattoos(artist.id, pagination),
+ isFeatureEnabled('STYLE_DNA_ENABLED') ? getStyleDna(artist.id) : Promise.resolve(null),
+ ]);
return (
@@ -83,6 +89,16 @@ export default async function ArtistProfilePage({ params, searchParams }: Props)
+ {/* Style DNA */}
+ {dna && (
+
+ }
+ sampleSize={dna.sampleSize}
+ />
+
+ )}
+
{/* Specialties */}
{artist.specialties.length > 0 && (
diff --git a/src/app/app/events/[slug]/page.tsx b/src/app/app/events/[slug]/page.tsx
new file mode 100644
index 0000000..ce717d9
--- /dev/null
+++ b/src/app/app/events/[slug]/page.tsx
@@ -0,0 +1,82 @@
+import { notFound } from 'next/navigation';
+import Link from 'next/link';
+import { GlassCard } from '@/components/ui/glass-card';
+import { GlassButton } from '@/components/ui/glass-button';
+import { GlassBadge } from '@/components/ui/glass-badge';
+import { getEventBySlug } from '@/backend/services/event-service';
+import { isFeatureEnabled } from '@/lib/feature-flags';
+import { auth } from '@/lib/auth';
+import { EventRsvpControls } from '@/components/features/events/event-rsvp-controls';
+
+interface Props {
+ params: Promise<{ slug: string }>;
+}
+
+export async function generateMetadata({ params }: Props) {
+ const { slug } = await params;
+ const event = await getEventBySlug(slug);
+ if (!event) return { title: 'Event not found' };
+ return {
+ title: `${event.name} — AETCH`,
+ description: event.description ?? `${event.name} on AETCH`,
+ };
+}
+
+export default async function EventDetailPage({ params }: Props) {
+ if (!isFeatureEnabled('EVENTS_ENABLED')) notFound();
+
+ const { slug } = await params;
+ const event = await getEventBySlug(slug);
+ if (!event) notFound();
+
+ const session = await auth();
+ const myRsvp = session?.user
+ ? event.attendees.find((a) => a.userId === session.user.id)?.status ?? null
+ : null;
+
+ return (
+
+
+
+
+
{event.name}
+
+ {new Date(event.startsAt).toLocaleString()} →{' '}
+ {new Date(event.endsAt).toLocaleString()}
+
+ {(event.venue || event.city) && (
+
+ {[event.venue, event.city, event.country].filter(Boolean).join(', ')}
+
+ )}
+
+
{event._count.attendees} going
+
+
+ {event.description && (
+
+ {event.description}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/app/app/events/page.tsx b/src/app/app/events/page.tsx
new file mode 100644
index 0000000..a704a4c
--- /dev/null
+++ b/src/app/app/events/page.tsx
@@ -0,0 +1,67 @@
+import Link from 'next/link';
+import { GlassCard } from '@/components/ui/glass-card';
+import { GlassBadge } from '@/components/ui/glass-badge';
+import { listUpcomingEvents } from '@/backend/services/event-service';
+import { isFeatureEnabled } from '@/lib/feature-flags';
+import { getPaginationParams } from '@/utils/pagination';
+
+export const metadata = {
+ title: 'Tattoo Events & Conventions — AETCH',
+ description: 'Upcoming tattoo conventions, expos and meet-ups around the world.',
+};
+
+export default async function EventsPage() {
+ if (!isFeatureEnabled('EVENTS_ENABLED')) {
+ return (
+
+
+ Events
+ Coming soon. Set FF_EVENTS=true to enable.
+
+
+ );
+ }
+
+ const { events } = await listUpcomingEvents(getPaginationParams(1, 24));
+
+ return (
+
+
+
Tattoo Conventions
+
Upcoming events around the world.
+
+
+ {events.length === 0 ? (
+
+ No upcoming events yet.
+
+ ) : (
+
+ {events.map((event) => (
+
+
+
+
{event.name}
+
+ {event._count.attendees} going
+
+
+
+ {new Date(event.startsAt).toLocaleDateString(undefined, {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ })}
+ {event.city && ` · ${event.city}`}
+
+ {event.description && (
+ {event.description}
+ )}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/app/app/learn/[slug]/page.tsx b/src/app/app/learn/[slug]/page.tsx
new file mode 100644
index 0000000..efa32df
--- /dev/null
+++ b/src/app/app/learn/[slug]/page.tsx
@@ -0,0 +1,48 @@
+import { notFound } from 'next/navigation';
+import Link from 'next/link';
+import { GlassCard } from '@/components/ui/glass-card';
+import { getLearnEntry, renderMarkdown, listLearnEntries } from '@/backend/services/learn-service';
+import { isFeatureEnabled } from '@/lib/feature-flags';
+
+interface Props {
+ params: Promise<{ slug: string }>;
+}
+
+export async function generateStaticParams() {
+ const entries = await listLearnEntries();
+ return entries.map((e) => ({ slug: e.slug }));
+}
+
+export async function generateMetadata({ params }: Props) {
+ const { slug } = await params;
+ const entry = await getLearnEntry(slug);
+ if (!entry) return { title: 'Not found' };
+ return {
+ title: `${entry.title} — AETCH Learn`,
+ description: entry.description,
+ };
+}
+
+export default async function LearnEntryPage({ params }: Props) {
+ if (!isFeatureEnabled('LEARN_ENABLED')) notFound();
+
+ const { slug } = await params;
+ const entry = await getLearnEntry(slug);
+ if (!entry) notFound();
+
+ const html = renderMarkdown(entry.body);
+
+ return (
+
+
+ ← Back to Learn
+
+
+
+
+
+ );
+}
diff --git a/src/app/app/learn/page.tsx b/src/app/app/learn/page.tsx
new file mode 100644
index 0000000..5accf50
--- /dev/null
+++ b/src/app/app/learn/page.tsx
@@ -0,0 +1,48 @@
+import Link from 'next/link';
+import { GlassCard } from '@/components/ui/glass-card';
+import { listLearnEntries } from '@/backend/services/learn-service';
+import { isFeatureEnabled } from '@/lib/feature-flags';
+
+export const metadata = {
+ title: 'Tattoo Learn — AETCH',
+ description: 'Tattoo education hub: prep, aftercare, styles, and more.',
+};
+
+export default async function LearnPage() {
+ if (!isFeatureEnabled('LEARN_ENABLED')) {
+ return (
+
+
+ Learn
+ Coming soon. Set FF_LEARN=true to enable.
+
+
+ );
+ }
+
+ const entries = await listLearnEntries();
+
+ return (
+
+
+
Tattoo Learn
+
+ Short, opinionated guides curated by AETCH artists.
+
+
+
+
+ {entries.map((entry) => (
+
+
+ {entry.title}
+ {entry.description && (
+ {entry.description}
+ )}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/app/longevity/page.tsx b/src/app/app/longevity/page.tsx
new file mode 100644
index 0000000..a3e88c5
--- /dev/null
+++ b/src/app/app/longevity/page.tsx
@@ -0,0 +1,40 @@
+import { redirect } from 'next/navigation';
+import { auth } from '@/lib/auth';
+import { isFeatureEnabled } from '@/lib/feature-flags';
+import { GlassCard } from '@/components/ui/glass-card';
+import { LongevitySimulator } from '@/components/features/longevity/longevity-simulator';
+
+export const dynamic = 'force-dynamic';
+
+export const metadata = {
+ title: 'Longevity Simulator — AETCH',
+ description: 'See how a tattoo will age over 1, 5, and 10 years.',
+};
+
+export default async function LongevityPage() {
+ const session = await auth();
+ if (!session?.user) redirect('/login?callbackUrl=/app/longevity');
+
+ if (!isFeatureEnabled('LONGEVITY_ENABLED')) {
+ return (
+
+
+ Longevity Simulator
+
+ This feature is coming soon. Set FF_LONGEVITY=true to enable it.
+
+
+
+ );
+ }
+
+ return (
+
+
+
Longevity Simulator
+
Preview how a tattoo will age over time.
+
+
+
+ );
+}
diff --git a/src/app/app/map/page.tsx b/src/app/app/map/page.tsx
new file mode 100644
index 0000000..340c513
--- /dev/null
+++ b/src/app/app/map/page.tsx
@@ -0,0 +1,35 @@
+import { GlassCard } from '@/components/ui/glass-card';
+import { ShopsMap } from '@/components/features/map/shops-map';
+import { isFeatureEnabled } from '@/lib/feature-flags';
+
+export const metadata = {
+ title: 'Tattoo Shops Map — AETCH',
+ description: 'Discover tattoo shops near you on the global AETCH map.',
+};
+
+export default function MapPage() {
+ if (!isFeatureEnabled('MAP_ENABLED')) {
+ return (
+
+
+ Shops Map
+
+ This feature is coming soon. Set FF_MAP=true to enable it.
+
+
+
+ );
+ }
+
+ const styleUrl = process.env.NEXT_PUBLIC_MAP_STYLE_URL;
+
+ return (
+
+
+
Tattoo Shops
+
Browse studios on the global map.
+
+
+
+ );
+}
diff --git a/src/backend/realtime/socket-server.ts b/src/backend/realtime/socket-server.ts
index 64ef277..ea18044 100644
--- a/src/backend/realtime/socket-server.ts
+++ b/src/backend/realtime/socket-server.ts
@@ -1,5 +1,6 @@
import { Server as HttpServer } from 'http';
import { Server, Socket } from 'socket.io';
+import { verifySocketToken } from '@/lib/socket-jwt';
export const runtime = 'nodejs';
@@ -44,6 +45,25 @@ export interface NotificationEvent {
// user id to socket mapping
const userSockets = new Map>();
+// prefer short-lived jwt; fall back to legacy `userId` only when SOCKET_JWT_SECRET
+// is unset (dev-only convenience). production should always require a token.
+function resolveUserId(socket: Socket): string | null {
+ const token = (socket.handshake.auth.token ?? socket.handshake.query.token) as
+ | string
+ | undefined;
+ if (token) {
+ try {
+ const payload = verifySocketToken(token);
+ return payload?.sub ?? null;
+ } catch {
+ return null;
+ }
+ }
+ if (process.env.SOCKET_JWT_SECRET) return null;
+ const userId = socket.handshake.auth.userId as string | undefined;
+ return userId ?? null;
+}
+
// initialize socket server
export function initSocketServer(httpServer: HttpServer): Server {
if (io) return io;
@@ -58,7 +78,7 @@ export function initSocketServer(httpServer: HttpServer): Server {
});
io.on('connection', (socket: Socket) => {
- const userId = socket.handshake.auth.userId as string;
+ const userId = resolveUserId(socket);
if (!userId) {
socket.disconnect();
diff --git a/src/backend/services/event-service.ts b/src/backend/services/event-service.ts
new file mode 100644
index 0000000..ef6a250
--- /dev/null
+++ b/src/backend/services/event-service.ts
@@ -0,0 +1,106 @@
+import { prisma } from '@/lib/prisma';
+import { type PaginationParams, buildPaginationMeta } from '@/utils/pagination';
+import type { EventAttendanceStatus } from '@prisma/client';
+
+interface CreateEventInput {
+ name: string;
+ slug: string;
+ description?: string;
+ venue?: string;
+ city?: string;
+ country?: string;
+ latitude?: number;
+ longitude?: number;
+ startsAt: Date;
+ endsAt: Date;
+ websiteUrl?: string;
+ coverImage?: string;
+}
+
+export async function createEvent(data: CreateEventInput) {
+ return prisma.event.create({ data });
+}
+
+export async function listUpcomingEvents(pagination: PaginationParams) {
+ const where = { endsAt: { gte: new Date() } } as const;
+ const [events, total] = await Promise.all([
+ prisma.event.findMany({
+ where,
+ orderBy: { startsAt: 'asc' },
+ skip: pagination.skip,
+ take: pagination.limit,
+ include: {
+ _count: { select: { attendees: true } },
+ },
+ }),
+ prisma.event.count({ where }),
+ ]);
+ return { events, pagination: buildPaginationMeta(total, pagination) };
+}
+
+export async function getEventBySlug(slug: string) {
+ return prisma.event.findUnique({
+ where: { slug },
+ include: {
+ attendees: {
+ take: 50,
+ orderBy: { createdAt: 'desc' },
+ },
+ _count: { select: { attendees: true } },
+ },
+ });
+}
+
+export async function rsvpToEvent(eventId: string, userId: string, status: EventAttendanceStatus) {
+ return prisma.eventAttendance.upsert({
+ where: { eventId_userId: { eventId, userId } },
+ create: { eventId, userId, status },
+ update: { status },
+ });
+}
+
+export async function cancelRsvp(eventId: string, userId: string) {
+ return prisma.eventAttendance.deleteMany({
+ where: { eventId, userId },
+ });
+}
+
+// build an RFC 5545 ICS calendar entry
+export function eventToIcs(event: {
+ id: string;
+ name: string;
+ description?: string | null;
+ venue?: string | null;
+ city?: string | null;
+ startsAt: Date;
+ endsAt: Date;
+ websiteUrl?: string | null;
+}): string {
+ const fmt = (d: Date) =>
+ d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '').slice(0, 15) + 'Z';
+ const escape = (s?: string | null) =>
+ s
+ ?.replace(/\\/g, '\\\\')
+ .replace(/\n/g, '\\n')
+ .replace(/,/g, '\\,')
+ .replace(/;/g, '\\;') ?? '';
+ return [
+ 'BEGIN:VCALENDAR',
+ 'VERSION:2.0',
+ 'PRODID:-//AETCH//Tattoo Events//EN',
+ 'CALSCALE:GREGORIAN',
+ 'BEGIN:VEVENT',
+ `UID:${event.id}@aetch.app`,
+ `DTSTAMP:${fmt(new Date())}`,
+ `DTSTART:${fmt(event.startsAt)}`,
+ `DTEND:${fmt(event.endsAt)}`,
+ `SUMMARY:${escape(event.name)}`,
+ event.description ? `DESCRIPTION:${escape(event.description)}` : '',
+ event.venue || event.city ? `LOCATION:${escape([event.venue, event.city].filter(Boolean).join(', '))}` : '',
+ event.websiteUrl ? `URL:${escape(event.websiteUrl)}` : '',
+ 'END:VEVENT',
+ 'END:VCALENDAR',
+ ]
+ .filter(Boolean)
+ .join('\r\n');
+}
diff --git a/src/backend/services/learn-service.ts b/src/backend/services/learn-service.ts
new file mode 100644
index 0000000..6b00bb9
--- /dev/null
+++ b/src/backend/services/learn-service.ts
@@ -0,0 +1,91 @@
+import { promises as fs } from 'fs';
+import path from 'path';
+
+export interface LearnEntry {
+ slug: string;
+ title: string;
+ description: string;
+ order: number;
+ body: string;
+}
+
+const CONTENT_DIR = path.join(process.cwd(), 'src', 'content', 'learn');
+
+export async function listLearnEntries(): Promise {
+ const files = await fs.readdir(CONTENT_DIR).catch(() => [] as string[]);
+ const entries: LearnEntry[] = [];
+ for (const file of files) {
+ if (!file.endsWith('.md')) continue;
+ const raw = await fs.readFile(path.join(CONTENT_DIR, file), 'utf8');
+ entries.push(parseEntry(raw, file.replace(/\.md$/, '')));
+ }
+ return entries.sort((a, b) => a.order - b.order);
+}
+
+export async function getLearnEntry(slug: string): Promise {
+ const safe = slug.replace(/[^a-z0-9-]/gi, '');
+ if (!safe) return null;
+ const filePath = path.join(CONTENT_DIR, `${safe}.md`);
+ const raw = await fs.readFile(filePath, 'utf8').catch(() => null);
+ if (!raw) return null;
+ return parseEntry(raw, safe);
+}
+
+function parseEntry(raw: string, fallbackSlug: string): LearnEntry {
+ const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n?/);
+ const meta: Record = {};
+ let body = raw;
+ if (fmMatch) {
+ body = raw.slice(fmMatch[0].length);
+ for (const line of fmMatch[1].split('\n')) {
+ const idx = line.indexOf(':');
+ if (idx === -1) continue;
+ const key = line.slice(0, idx).trim();
+ const value = line.slice(idx + 1).trim();
+ meta[key] = value;
+ }
+ }
+ return {
+ slug: meta.slug ?? fallbackSlug,
+ title: meta.title ?? fallbackSlug,
+ description: meta.description ?? '',
+ order: Number(meta.order ?? 99),
+ body,
+ };
+}
+
+// minimal markdown → html. handles headings, lists, paragraphs, bold, code, and inline links.
+export function renderMarkdown(md: string): string {
+ let html = md;
+ html = html
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+
+ html = html.replace(/^# (.*)$/gm, '$1
');
+ html = html.replace(/^## (.*)$/gm, '$1
');
+ html = html.replace(/^### (.*)$/gm, '$1
');
+
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1');
+ html = html.replace(/`([^`]+)`/g, '$1');
+ html = html.replace(/\*\*([^*]+)\*\*/g, '$1');
+
+ html = html.replace(/((?:^- .+\n?)+)/gm, (block) => {
+ const items = block
+ .trim()
+ .split('\n')
+ .map((line) => `${line.replace(/^- /, '')}`)
+ .join('');
+ return ``;
+ });
+
+ html = html
+ .split(/\n{2,}/)
+ .map((para) =>
+ para.startsWith('${para}
`,
+ )
+ .join('\n');
+
+ return html;
+}
diff --git a/src/backend/services/link-preview-service.ts b/src/backend/services/link-preview-service.ts
new file mode 100644
index 0000000..02db5fe
--- /dev/null
+++ b/src/backend/services/link-preview-service.ts
@@ -0,0 +1,152 @@
+import { prisma } from '@/lib/prisma';
+import { isFeatureEnabled } from '@/lib/feature-flags';
+import { safeFetch } from '@/lib/ssrf';
+import { logger } from '@/lib/logger';
+
+const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
+const FETCH_BYTE_CAP = 200_000;
+
+interface PreviewData {
+ url: string;
+ canonicalUrl?: string;
+ title?: string;
+ description?: string;
+ image?: string;
+ siteName?: string;
+}
+
+export async function getLinkPreview(rawUrl: string): Promise {
+ if (!isFeatureEnabled('LINK_PREVIEWS_ENABLED')) return null;
+
+ const url = normalizeUrl(rawUrl);
+ if (!url) return null;
+
+ const cached = await prisma.linkPreview.findUnique({ where: { url } });
+ if (cached && cached.expiresAt > new Date()) {
+ return toPreviewData(cached);
+ }
+
+ let preview: PreviewData;
+ try {
+ preview = await fetchPreview(url);
+ } catch (err) {
+ logger.warn({ err, url }, 'link preview fetch failed');
+ return null;
+ }
+
+ await prisma.linkPreview.upsert({
+ where: { url },
+ create: {
+ url,
+ canonicalUrl: preview.canonicalUrl,
+ title: preview.title,
+ description: preview.description,
+ image: preview.image,
+ siteName: preview.siteName,
+ expiresAt: new Date(Date.now() + CACHE_TTL_MS),
+ },
+ update: {
+ canonicalUrl: preview.canonicalUrl,
+ title: preview.title,
+ description: preview.description,
+ image: preview.image,
+ siteName: preview.siteName,
+ fetchedAt: new Date(),
+ expiresAt: new Date(Date.now() + CACHE_TTL_MS),
+ },
+ });
+
+ return preview;
+}
+
+function normalizeUrl(rawUrl: string): string | null {
+ try {
+ const u = new URL(rawUrl);
+ u.hash = '';
+ return u.toString();
+ } catch {
+ return null;
+ }
+}
+
+async function fetchPreview(url: string): Promise {
+ const res = await safeFetch(url, {
+ maxBytes: FETCH_BYTE_CAP,
+ headers: {
+ accept: 'text/html,application/xhtml+xml',
+ 'user-agent': 'AetchBot/1.0 (link preview)',
+ },
+ });
+ const contentType = res.headers.get('content-type') ?? '';
+ if (!contentType.includes('html')) {
+ return { url };
+ }
+ const text = (await res.text()).slice(0, FETCH_BYTE_CAP);
+ return { url, ...parseMetadata(text, url) };
+}
+
+function parseMetadata(html: string, baseUrl: string): Omit {
+ const get = (re: RegExp) => {
+ const m = html.match(re);
+ return m ? decodeHtml(m[1].trim()) : undefined;
+ };
+
+ const ogTitle = get(/]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i);
+ const ogDesc = get(/]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i);
+ const ogImage = get(/]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i);
+ const ogSite = get(/]+property=["']og:site_name["'][^>]+content=["']([^"']+)["']/i);
+ const twImage = get(/]+name=["']twitter:image["'][^>]+content=["']([^"']+)["']/i);
+ const desc = get(/]+name=["']description["'][^>]+content=["']([^"']+)["']/i);
+ const title = get(/]*>([^<]+)<\/title>/i);
+ const canonical = get(/]+rel=["']canonical["'][^>]+href=["']([^"']+)["']/i);
+
+ const image = ogImage ?? twImage;
+ const absoluteImage = image ? toAbsolute(image, baseUrl) : undefined;
+ const absoluteCanonical = canonical ? toAbsolute(canonical, baseUrl) : undefined;
+
+ return {
+ canonicalUrl: absoluteCanonical,
+ title: ogTitle ?? title,
+ description: ogDesc ?? desc,
+ image: absoluteImage,
+ siteName: ogSite,
+ };
+}
+
+function decodeHtml(s: string): string {
+ return s
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/ /g, ' ');
+}
+
+function toAbsolute(href: string, base: string): string {
+ try {
+ return new URL(href, base).toString();
+ } catch {
+ return href;
+ }
+}
+
+interface DbPreview {
+ url: string;
+ canonicalUrl: string | null;
+ title: string | null;
+ description: string | null;
+ image: string | null;
+ siteName: string | null;
+}
+
+function toPreviewData(row: DbPreview): PreviewData {
+ return {
+ url: row.url,
+ canonicalUrl: row.canonicalUrl ?? undefined,
+ title: row.title ?? undefined,
+ description: row.description ?? undefined,
+ image: row.image ?? undefined,
+ siteName: row.siteName ?? undefined,
+ };
+}
diff --git a/src/backend/services/longevity-service.ts b/src/backend/services/longevity-service.ts
new file mode 100644
index 0000000..9920e21
--- /dev/null
+++ b/src/backend/services/longevity-service.ts
@@ -0,0 +1,117 @@
+// v0 longevity simulator — derives aged-tattoo CSS filter parameters from
+// inputs (placement, style, line thickness, color palette). v1 will swap to
+// stable diffusion img2img with an aged-tattoo lora.
+
+export type AgeYears = 1 | 5 | 10;
+export type LineThickness = 'fine' | 'medium' | 'bold';
+export type ColorPalette = 'COLOR' | 'BLACK_AND_GREY' | 'MIXED';
+
+interface SimulateInput {
+ ageYears: AgeYears;
+ lineThickness?: LineThickness;
+ colorPalette?: ColorPalette;
+ placement?: string;
+ style?: string;
+}
+
+export interface AgedFilter {
+ ageYears: AgeYears;
+ blurPx: number;
+ brightness: number;
+ contrast: number;
+ saturate: number;
+ sepia: number;
+ // svg displacement seed for irregular fade
+ noiseSeed: number;
+ notes: string[];
+}
+
+// placements with high friction or sun exposure age tattoos faster
+const PLACEMENT_AGING_BIAS: Record = {
+ hand: 1.4,
+ finger: 1.6,
+ foot: 1.4,
+ ankle: 1.2,
+ neck: 1.15,
+ ribs: 1.05,
+ chest: 1.0,
+ shoulder: 0.95,
+ back: 0.9,
+ thigh: 0.92,
+ forearm: 0.95,
+};
+
+// styles with delicate work degrade faster
+const STYLE_AGING_BIAS: Record = {
+ FINE_LINE: 1.3,
+ MINIMALIST: 1.2,
+ WATERCOLOR: 1.4,
+ REALISM: 1.1,
+ TRADITIONAL: 0.85,
+ BLACKWORK: 0.85,
+ TRIBAL: 0.85,
+ JAPANESE: 0.9,
+};
+
+const LINE_THICKNESS_BIAS: Record = {
+ fine: 1.25,
+ medium: 1.0,
+ bold: 0.8,
+};
+
+// color tattoos lose vibrancy faster than black-and-grey
+const COLOR_BIAS: Record = {
+ COLOR: 1.2,
+ MIXED: 1.05,
+ BLACK_AND_GREY: 0.9,
+};
+
+export function simulateAging(input: SimulateInput): AgedFilter {
+ const placementBias = input.placement ? (PLACEMENT_AGING_BIAS[input.placement] ?? 1) : 1;
+ const styleBias = input.style ? (STYLE_AGING_BIAS[input.style] ?? 1) : 1;
+ const thicknessBias = LINE_THICKNESS_BIAS[input.lineThickness ?? 'medium'];
+ const colorBias = COLOR_BIAS[input.colorPalette ?? 'BLACK_AND_GREY'];
+
+ const composite = placementBias * styleBias * thicknessBias * colorBias;
+
+ // base aging curve — log-like fade rather than linear
+ const ageFactor = Math.log10(1 + input.ageYears) * composite;
+
+ const blurPx = round2(0.3 + ageFactor * 0.7);
+ const brightness = round2(1 + ageFactor * 0.05);
+ const contrast = round2(Math.max(0.6, 1 - ageFactor * 0.18));
+ const saturate = round2(Math.max(0.4, 1 - ageFactor * 0.32));
+ const sepia = round2(Math.min(0.4, ageFactor * 0.12));
+
+ const notes = buildNotes(input, composite);
+ return {
+ ageYears: input.ageYears,
+ blurPx,
+ brightness,
+ contrast,
+ saturate,
+ sepia,
+ noiseSeed: Math.floor(input.ageYears * 137 + composite * 1000),
+ notes,
+ };
+}
+
+function buildNotes(input: SimulateInput, composite: number): string[] {
+ const notes: string[] = [];
+ if (input.lineThickness === 'fine') notes.push('Fine lines tend to soften within 2-3 years.');
+ if (input.colorPalette === 'COLOR')
+ notes.push('Color saturation fades faster than black-and-grey.');
+ if (input.placement && (PLACEMENT_AGING_BIAS[input.placement] ?? 1) > 1.1)
+ notes.push('High-friction placement accelerates fading.');
+ if (composite > 1.3) notes.push('Aftercare and sunscreen will matter a lot here.');
+ return notes;
+}
+
+export function simulateAgingTimeline(input: Omit): AgedFilter[] {
+ const ages: AgeYears[] = [1, 5, 10];
+ return ages.map((ageYears) => simulateAging({ ...input, ageYears }));
+}
+
+function round2(n: number): number {
+ return Math.round(n * 100) / 100;
+}
diff --git a/src/backend/services/notification-service.ts b/src/backend/services/notification-service.ts
index 3feab2a..bd04429 100644
--- a/src/backend/services/notification-service.ts
+++ b/src/backend/services/notification-service.ts
@@ -1,5 +1,8 @@
import { prisma } from '@/lib/prisma';
import type { NotificationType } from '@prisma/client';
+import { isFeatureEnabled } from '@/lib/feature-flags';
+import { sendPushTickle, vapidConfigured } from '@/lib/web-push';
+import { logger } from '@/lib/logger';
export async function createNotification(data: {
userId: string;
@@ -9,7 +12,28 @@ export async function createNotification(data: {
bookingId?: string;
link?: string;
}) {
- return prisma.notification.create({ data });
+ const notification = await prisma.notification.create({ data });
+ if (isFeatureEnabled('WEB_PUSH_ENABLED') && vapidConfigured()) {
+ pushFanout(data.userId).catch((err) =>
+ logger.warn({ err, userId: data.userId }, 'web push fanout failed'),
+ );
+ }
+ return notification;
+}
+
+async function pushFanout(userId: string): Promise {
+ const subs = await prisma.pushSubscription.findMany({ where: { userId } });
+ for (const sub of subs) {
+ const ok = await sendPushTickle({
+ endpoint: sub.endpoint,
+ p256dh: sub.p256dh,
+ authKey: sub.authKey,
+ });
+ if (!ok) {
+ // dead subscription — purge so we stop pushing to it
+ await prisma.pushSubscription.deleteMany({ where: { endpoint: sub.endpoint } });
+ }
+ }
}
export async function getUserNotifications(userId: string, limit = 20) {
diff --git a/src/backend/services/shop-service.ts b/src/backend/services/shop-service.ts
index c6d30ac..8e36bad 100644
--- a/src/backend/services/shop-service.ts
+++ b/src/backend/services/shop-service.ts
@@ -49,6 +49,28 @@ export async function getShopByOwnerId(ownerId: string) {
});
}
+// shops with lat/lng for the global map. caps at 5000 to stay light on the wire.
+export async function listShopsForMap() {
+ return prisma.shop.findMany({
+ where: {
+ latitude: { not: null },
+ longitude: { not: null },
+ },
+ select: {
+ id: true,
+ slug: true,
+ name: true,
+ city: true,
+ country: true,
+ latitude: true,
+ longitude: true,
+ image: true,
+ verified: true,
+ },
+ take: 5000,
+ });
+}
+
// list distinct cities — feeds /app/shops/[city] sitemap
export async function listShopCities(limit = 200) {
const rows = await prisma.shop.findMany({
diff --git a/src/backend/services/style-dna-service.ts b/src/backend/services/style-dna-service.ts
new file mode 100644
index 0000000..9f28573
--- /dev/null
+++ b/src/backend/services/style-dna-service.ts
@@ -0,0 +1,158 @@
+import { prisma } from '@/lib/prisma';
+import { isFeatureEnabled } from '@/lib/feature-flags';
+import { logger } from '@/lib/logger';
+import type { TattooStyle } from '@prisma/client';
+
+interface DnaWeights {
+ [style: string]: number;
+}
+
+interface DnaResult {
+ artistId: string;
+ weights: DnaWeights;
+ sampleSize: number;
+ embedding: number[];
+}
+
+const STYLE_AXES: TattooStyle[] = [
+ 'TRADITIONAL',
+ 'NEO_TRADITIONAL',
+ 'JAPANESE',
+ 'BLACKWORK',
+ 'FINE_LINE',
+ 'MINIMALIST',
+ 'REALISM',
+ 'TRIBAL',
+ 'BIOMECHANICAL',
+ 'ABSTRACT',
+ 'WATERCOLOR',
+ 'GEOMETRIC',
+ 'DOTWORK',
+ 'CHICANO',
+ 'OTHER',
+];
+
+// aggregate style weights from an artist's portfolio.
+// each tattoo contributes to its declared styles + likes-weighted importance.
+export async function computeArtistStyleDna(artistId: string): Promise {
+ const tattoos = await prisma.tattoo.findMany({
+ where: { artistId },
+ select: { styles: true, likesCount: true },
+ });
+
+ const totals: Record = {};
+ let totalWeight = 0;
+
+ for (const t of tattoos) {
+ const importance = 1 + Math.log10(1 + (t.likesCount ?? 0));
+ for (const style of t.styles) {
+ totals[style] = (totals[style] ?? 0) + importance;
+ totalWeight += importance;
+ }
+ }
+
+ const weights: DnaWeights = {};
+ if (totalWeight > 0) {
+ for (const [style, sum] of Object.entries(totals)) {
+ weights[style] = round3(sum / totalWeight);
+ }
+ }
+
+ // 15-dim embedding vector aligned to STYLE_AXES — placeholder for clip image embeddings
+ const embedding = STYLE_AXES.map((style) => weights[style] ?? 0);
+
+ return {
+ artistId,
+ weights,
+ sampleSize: tattoos.length,
+ embedding,
+ };
+}
+
+export async function persistStyleDna(result: DnaResult) {
+ await prisma.artistStyleDna.upsert({
+ where: { artistId: result.artistId },
+ create: {
+ artistId: result.artistId,
+ weights: result.weights,
+ embedding: result.embedding,
+ sampleSize: result.sampleSize,
+ },
+ update: {
+ weights: result.weights,
+ embedding: result.embedding,
+ sampleSize: result.sampleSize,
+ computedAt: new Date(),
+ },
+ });
+}
+
+export async function getStyleDna(artistId: string) {
+ return prisma.artistStyleDna.findUnique({ where: { artistId } });
+}
+
+// recompute every artist's style DNA — called from /api/cron/style-dna weekly
+export async function recomputeAllStyleDna(): Promise<{ processed: number }> {
+ if (!isFeatureEnabled('STYLE_DNA_ENABLED')) {
+ logger.info('style-dna recompute skipped — flag off');
+ return { processed: 0 };
+ }
+ const artists = await prisma.artist.findMany({ select: { id: true } });
+ let processed = 0;
+ for (const a of artists) {
+ try {
+ const result = await computeArtistStyleDna(a.id);
+ if (result.sampleSize > 0) {
+ await persistStyleDna(result);
+ processed += 1;
+ }
+ } catch (err) {
+ logger.warn({ err, artistId: a.id }, 'style-dna compute failed');
+ }
+ }
+ return { processed };
+}
+
+// rank artists by cosine similarity to a target dna vector
+export async function findSimilarArtists(artistId: string, limit = 8) {
+ const target = await getStyleDna(artistId);
+ if (!target || target.embedding.length === 0) return [];
+ const all = await prisma.artistStyleDna.findMany({
+ where: { artistId: { not: artistId } },
+ include: {
+ artist: {
+ select: { id: true, slug: true, displayName: true, user: { select: { image: true } } },
+ },
+ },
+ });
+ type Row = (typeof all)[number];
+ const scored = all
+ .map((row: Row) => ({
+ artist: row.artist,
+ score: cosineSimilarity(target.embedding, row.embedding),
+ }))
+ .filter((row: { score: number }) => row.score > 0)
+ .sort((a: { score: number }, b: { score: number }) => b.score - a.score)
+ .slice(0, limit);
+ return scored;
+}
+
+function cosineSimilarity(a: number[], b: number[]): number {
+ if (a.length !== b.length) return 0;
+ let dot = 0;
+ let na = 0;
+ let nb = 0;
+ for (let i = 0; i < a.length; i++) {
+ dot += a[i] * b[i];
+ na += a[i] * a[i];
+ nb += b[i] * b[i];
+ }
+ if (na === 0 || nb === 0) return 0;
+ return dot / Math.sqrt(na * nb);
+}
+
+function round3(n: number): number {
+ return Math.round(n * 1000) / 1000;
+}
+
+export const STYLE_DNA_AXES = STYLE_AXES;
diff --git a/src/backend/services/user-service.ts b/src/backend/services/user-service.ts
index 1d7a600..ce1ab9c 100644
--- a/src/backend/services/user-service.ts
+++ b/src/backend/services/user-service.ts
@@ -1,7 +1,8 @@
import { prisma } from '@/lib/prisma';
import type { UserRole } from '@prisma/client';
+import { rotateUserSessions } from '@/lib/session-rotate';
-// add role to user
+// add role to user — rotates sessions so new role takes effect immediately
export async function addUserRole(userId: string, role: UserRole) {
const user = await prisma.user.findUnique({
where: { id: userId },
@@ -9,15 +10,20 @@ export async function addUserRole(userId: string, role: UserRole) {
});
if (!user) throw new Error('User not found');
- const roles = user.roles.includes(role) ? user.roles : [...user.roles, role];
+ if (user.roles.includes(role)) {
+ return prisma.user.update({ where: { id: userId }, data: {} });
+ }
- return prisma.user.update({
+ const roles = [...user.roles, role];
+ const updated = await prisma.user.update({
where: { id: userId },
data: { roles },
});
+ await rotateUserSessions(userId);
+ return updated;
}
-// remove role from user
+// remove role from user — rotates sessions so revoked role drops immediately
export async function removeUserRole(userId: string, role: UserRole) {
const user = await prisma.user.findUnique({
where: { id: userId },
@@ -25,10 +31,16 @@ export async function removeUserRole(userId: string, role: UserRole) {
});
if (!user) throw new Error('User not found');
- return prisma.user.update({
+ if (!user.roles.includes(role)) {
+ return prisma.user.update({ where: { id: userId }, data: {} });
+ }
+
+ const updated = await prisma.user.update({
where: { id: userId },
data: { roles: user.roles.filter((r) => r !== role) },
});
+ await rotateUserSessions(userId);
+ return updated;
}
export async function completeUserOnboarding(
diff --git a/src/components/features/ai/ai-prompt-form.tsx b/src/components/features/ai/ai-prompt-form.tsx
index 08ea092..fd2d25d 100644
--- a/src/components/features/ai/ai-prompt-form.tsx
+++ b/src/components/features/ai/ai-prompt-form.tsx
@@ -10,6 +10,14 @@ import { AIPlacementSelector } from './ai-placement-selector';
import { COLOR_TYPES, AI_COMPLEXITIES } from '@/lib/validations';
import { cn } from '@/utils/cn';
import { Sparkles } from 'lucide-react';
+import { PriceEstimateWidget } from '@/components/features/pricing/price-estimate-widget';
+
+const COMPLEXITY_TO_SIZE: Record = {
+ simple: 'SMALL',
+ moderate: 'MEDIUM',
+ detailed: 'LARGE',
+ complex: 'EXTRA_LARGE',
+};
const COLOR_LABELS: Record = {
COLOR: 'Full Color',
@@ -152,6 +160,14 @@ export function AIPromptForm({ onGenerate }: AIPromptFormProps) {
+
+
Generate Tattoo Design
diff --git a/src/components/features/artists/style-dna-chart.tsx b/src/components/features/artists/style-dna-chart.tsx
new file mode 100644
index 0000000..3ccb8eb
--- /dev/null
+++ b/src/components/features/artists/style-dna-chart.tsx
@@ -0,0 +1,66 @@
+'use client';
+
+import { useMemo } from 'react';
+import { GlassCard } from '@/components/ui/glass-card';
+import { GlassBadge } from '@/components/ui/glass-badge';
+import { STYLE_LABELS } from '@/lib/validations';
+
+interface StyleDnaChartProps {
+ weights: Record;
+ sampleSize: number;
+}
+
+// horizontal bars + top-3 badge summary. SVG kept simple for accessibility.
+export function StyleDnaChart({ weights, sampleSize }: StyleDnaChartProps) {
+ const sorted = useMemo(
+ () =>
+ Object.entries(weights)
+ .filter(([, v]) => v > 0)
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 8),
+ [weights],
+ );
+
+ if (sorted.length === 0) {
+ return null;
+ }
+
+ const top = sorted.slice(0, 3);
+
+ return (
+
+
+
Style DNA
+ {sampleSize} tattoos analyzed
+
+
+
+ {top.map(([style, weight]) => (
+
+ {STYLE_LABELS[style] ?? style} · {Math.round(weight * 100)}%
+
+ ))}
+
+
+
+ {sorted.map(([style, weight]) => {
+ const pct = Math.round(weight * 100);
+ return (
+
+
+ {STYLE_LABELS[style] ?? style}
+ {pct}%
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/components/features/booking/booking-form.tsx b/src/components/features/booking/booking-form.tsx
index 0507744..99b2ad9 100644
--- a/src/components/features/booking/booking-form.tsx
+++ b/src/components/features/booking/booking-form.tsx
@@ -9,6 +9,7 @@ import { GlassTextarea } from '@/components/ui/glass-textarea';
import { FormError } from '@/components/forms/form-error';
import { cn } from '@/utils/cn';
import { BODY_PLACEMENTS, PLACEMENT_LABELS } from '@/lib/validations';
+import { PriceEstimateWidget } from '@/components/features/pricing/price-estimate-widget';
const SIZES = [
{ value: 'SMALL', label: 'Small (< 3")' },
@@ -143,6 +144,12 @@ export function BookingForm({ artistId, artistSlug }: BookingFormProps) {
+
+
(initialStatus);
+ const [busy, setBusy] = useState(false);
+
+ const choose = async (value: RsvpStatus) => {
+ setBusy(true);
+ try {
+ const res = await fetch(`/api/events/${slug}/rsvp`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ status: value }),
+ });
+ if (res.ok) setStatus(value);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const cancel = async () => {
+ setBusy(true);
+ try {
+ const res = await fetch(`/api/events/${slug}/rsvp`, { method: 'DELETE' });
+ if (res.ok) setStatus(undefined);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+ {OPTIONS.map((opt) => (
+
+ ))}
+ {status && (
+
+ Clear
+
+ )}
+
+ );
+}
diff --git a/src/components/features/longevity/longevity-simulator.tsx b/src/components/features/longevity/longevity-simulator.tsx
new file mode 100644
index 0000000..7beb1b4
--- /dev/null
+++ b/src/components/features/longevity/longevity-simulator.tsx
@@ -0,0 +1,208 @@
+'use client';
+
+import { useMemo, useState } from 'react';
+import { GlassButton } from '@/components/ui/glass-button';
+import { GlassCard } from '@/components/ui/glass-card';
+import { GlassInput } from '@/components/ui/glass-input';
+import { cn } from '@/utils/cn';
+import { TATTOO_STYLES, STYLE_LABELS, BODY_PLACEMENTS, PLACEMENT_LABELS } from '@/lib/validations';
+
+type AgeYears = 1 | 5 | 10;
+const AGES: AgeYears[] = [1, 5, 10];
+
+interface AgedFilter {
+ ageYears: AgeYears;
+ blurPx: number;
+ brightness: number;
+ contrast: number;
+ saturate: number;
+ sepia: number;
+ notes: string[];
+}
+
+interface Result {
+ imageUrl: string;
+ timeline: AgedFilter[];
+}
+
+const LINE_OPTIONS = ['fine', 'medium', 'bold'] as const;
+const COLOR_OPTIONS = ['BLACK_AND_GREY', 'MIXED', 'COLOR'] as const;
+
+export function LongevitySimulator() {
+ const [imageUrl, setImageUrl] = useState('');
+ const [lineThickness, setLineThickness] = useState<(typeof LINE_OPTIONS)[number]>('medium');
+ const [colorPalette, setColorPalette] =
+ useState<(typeof COLOR_OPTIONS)[number]>('BLACK_AND_GREY');
+ const [placement, setPlacement] = useState('');
+ const [style, setStyle] = useState('');
+ const [age, setAge] = useState(1);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [result, setResult] = useState(null);
+
+ const current = useMemo(() => result?.timeline.find((t) => t.ageYears === age), [result, age]);
+
+ const submit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError(null);
+ setResult(null);
+ setLoading(true);
+ try {
+ const res = await fetch('/api/longevity', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ imageUrl,
+ lineThickness,
+ colorPalette,
+ placement: placement || undefined,
+ style: style || undefined,
+ }),
+ });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data?.error ?? 'Simulation failed');
+ setResult({ imageUrl: data.imageUrl, timeline: data.timeline });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {result && current && (
+
+
+ {AGES.map((y) => (
+
+ ))}
+
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

+
+
+ {current.notes.length > 0 && (
+
+ {current.notes.map((note) => (
+ - {note}
+ ))}
+
+ )}
+
+ v0 simulation — visual approximation only. Real aging depends on individual skin, sun
+ exposure, and aftercare.
+
+
+ )}
+
+ );
+}
diff --git a/src/components/features/map/shops-map.tsx b/src/components/features/map/shops-map.tsx
new file mode 100644
index 0000000..7dc1594
--- /dev/null
+++ b/src/components/features/map/shops-map.tsx
@@ -0,0 +1,165 @@
+'use client';
+
+import { useEffect, useMemo, useState } from 'react';
+import Link from 'next/link';
+import Script from 'next/script';
+import { GlassCard } from '@/components/ui/glass-card';
+import { GlassBadge } from '@/components/ui/glass-badge';
+import { GlassInput } from '@/components/ui/glass-input';
+
+interface ShopFeature {
+ id: string;
+ slug: string;
+ name: string;
+ city?: string | null;
+ country?: string | null;
+ image?: string | null;
+ verified: boolean;
+ lng: number;
+ lat: number;
+}
+
+interface ShopsMapProps {
+ styleUrl?: string;
+}
+
+interface MaplibreMarker {
+ setLngLat: (l: [number, number]) => MaplibreMarker;
+ setPopup: (p: unknown) => MaplibreMarker;
+ addTo: (m: unknown) => MaplibreMarker;
+}
+
+interface MaplibreLib {
+ Map: new (opts: unknown) => unknown;
+ Marker: new () => MaplibreMarker;
+ Popup: new (opts?: unknown) => { setHTML: (s: string) => unknown };
+ LngLatBounds: new () => {
+ extend: (l: [number, number]) => unknown;
+ isEmpty: () => boolean;
+ };
+}
+
+declare global {
+ interface Window {
+ maplibregl?: MaplibreLib;
+ }
+}
+
+const FALLBACK_STYLE = 'https://demotiles.maplibre.org/style.json';
+
+export function ShopsMap({ styleUrl }: ShopsMapProps) {
+ const [shops, setShops] = useState([]);
+ const [query, setQuery] = useState('');
+ const [active, setActive] = useState(null);
+ const [scriptReady, setScriptReady] = useState(false);
+
+ useEffect(() => {
+ fetch('/api/shops/map')
+ .then((r) => r.json())
+ .then((data) => setShops(data.shops ?? []))
+ .catch(() => setShops([]));
+ }, []);
+
+ // initialize maplibre once both shop data + script have loaded
+ useEffect(() => {
+ if (!scriptReady || !shops.length) return;
+ const ml = window.maplibregl;
+ if (!ml) return;
+ const map = new ml.Map({
+ container: 'shops-map',
+ style: styleUrl || FALLBACK_STYLE,
+ center: [shops[0].lng, shops[0].lat],
+ zoom: 1.5,
+ cooperativeGestures: true,
+ });
+ const bounds = new ml.LngLatBounds();
+ for (const s of shops) {
+ const popup = new ml.Popup({ offset: 24 }).setHTML(
+ `${escapeHtml(s.name)}` +
+ (s.city ? `${escapeHtml(s.city)}
` : ''),
+ );
+ new ml.Marker().setLngLat([s.lng, s.lat]).setPopup(popup).addTo(map);
+ bounds.extend([s.lng, s.lat]);
+ }
+ if (!bounds.isEmpty()) {
+ // @ts-expect-error map is the maplibre instance, fitBounds exists at runtime
+ map.fitBounds(bounds, { padding: 40, maxZoom: 6, duration: 0 });
+ }
+ }, [scriptReady, shops, styleUrl]);
+
+ const filtered = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ if (!q) return shops;
+ return shops.filter(
+ (s) =>
+ s.name.toLowerCase().includes(q) ||
+ s.city?.toLowerCase().includes(q) ||
+ s.country?.toLowerCase().includes(q),
+ );
+ }, [query, shops]);
+
+ return (
+
+