diff --git a/.env.example b/.env.example index c25057d..e50ec40 100644 --- a/.env.example +++ b/.env.example @@ -32,7 +32,9 @@ SHOPIFY_WEBHOOK_SECRET="" # --- AI (provider-independent; default is the offline deterministic mock) --- # Model IDs below are the current latest (2026-07); override to pin/upgrade without code changes. -AI_PROVIDER="mock" # mock | anthropic | openai | gemini +AI_PROVIDER="mock" # mock | anthropic | openai | gemini | xai +AI_PROVIDER_PRIORITY="openai,anthropic,gemini,xai" +AI_DEMO_MODE="false" ANTHROPIC_API_KEY="" ANTHROPIC_MODEL="claude-haiku-4-5-20251001" # e.g. claude-haiku-4-5-20251001 | claude-sonnet-5 | claude-opus-4-8 OPENAI_API_KEY="" @@ -56,7 +58,7 @@ COMMERCE_FREE_SHIPPING_THRESHOLD_NGN="500000" COMMERCE_FLAT_SHIPPING_NGN="2500" # --- Feature flags (comma-separated). Prefix with ! to force-disable a default-on flag. --- -# Available: shopify_commerce, ai_concierge, loyalty_rewards, referral_rewards, +# Available: shopify_commerce, ai_concierge, concierge_v2, loyalty_rewards, referral_rewards, # sample_credits, whatsapp_marketing, agentic_feed FEATURE_FLAGS="" @@ -65,3 +67,15 @@ FEATURE_FLAGS="" # per-instance limiter is used (documented limitation in docs/SECURITY_REVIEW.md). UPSTASH_REDIS_REST_URL="" UPSTASH_REDIS_REST_TOKEN="" + +# --- Concierge V2 limits and cost controls --- +CONCIERGE_GUEST_QUESTIONS="1" +CONCIERGE_AUTH_PER_MINUTE="12" +CONCIERGE_AUTH_DAILY="100" +CONCIERGE_WEB_DAILY="15" +CONCIERGE_MAX_TOOL_CALLS="8" +CONCIERGE_MAX_SEARCH_CALLS="3" +CONCIERGE_MAX_OUTPUT_TOKENS="1400" +CONCIERGE_DAILY_SPEND_USD="25" +CONCIERGE_MONTHLY_SPEND_USD="300" +CONCIERGE_CATALOGUE_ONLY="false" diff --git a/.github/workflows/storefront-e2e.yml b/.github/workflows/storefront-e2e.yml index e9f0b53..7d98889 100644 --- a/.github/workflows/storefront-e2e.yml +++ b/.github/workflows/storefront-e2e.yml @@ -13,11 +13,26 @@ jobs: playwright: runs-on: ubuntu-latest timeout-minutes: 20 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: fade_e2e + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d fade_e2e" + --health-interval 10s + --health-timeout 5s + --health-retries 5 defaults: run: working-directory: e2e env: - E2E_BASE_URL: ${{ vars.E2E_BASE_URL || 'https://9thluxe-store-two.vercel.app' }} + E2E_BASE_URL: http://127.0.0.1:3000 + DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/fade_e2e steps: - name: Checkout @@ -33,12 +48,46 @@ jobs: - name: Install test dependencies run: npm ci + - name: Install storefront dependencies + working-directory: . + run: npm ci + + - name: Apply database migrations + working-directory: . + run: npx prisma migrate deploy + + - name: Seed storefront data + working-directory: . + run: npm run seed + - name: Install Chromium run: npx playwright install --with-deps chromium + - name: Build storefront + working-directory: . + run: npm run build + + - name: Start storefront + working-directory: . + run: | + npm run start > /tmp/storefront.log 2>&1 & + for attempt in {1..60}; do + if curl --fail --silent http://127.0.0.1:3000/ > /dev/null; then + exit 0 + fi + sleep 2 + done + cat /tmp/storefront.log + exit 1 + - name: Run storefront verification run: npm test + - name: Print storefront logs on failure + if: failure() + working-directory: . + run: cat /tmp/storefront.log || true + - name: Upload Playwright report if: always() uses: actions/upload-artifact@v4 diff --git a/README.md b/README.md index d8d0371..f9a67e9 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Fádé Essence is a complete e-commerce solution featuring: - **Inventory management** with low stock alerts - **Order tracking** and status management - **Newsletter campaigns** with rich text editor +- **Perfume Intelligence concierge** with multi-turn guidance, grounded catalogue recommendations, optional cited web research, and saved conversations - **SEO optimization** with dynamic sitemaps and metadata ## ✨ Key Features @@ -54,6 +55,7 @@ Fádé Essence is a complete e-commerce solution featuring: - **Social Media Integration**: Links to Instagram, X (Twitter), WhatsApp, TikTok, and Facebook - **Responsive Design**: Fully responsive across all devices - **Dark Mode**: Theme toggle for light/dark mode +- **Perfume Intelligence Concierge**: Ask perfume-knowledge, comparison, layering, climate, occasion, and catalogue questions in a responsive multi-turn workspace ### 👨‍💼 Admin Features @@ -109,6 +111,7 @@ Fádé Essence is a complete e-commerce solution featuring: - **Dashboard Stats**: Overview of products, orders, and revenue - **Product Statistics**: Total products, active products, low stock items - **Order Analytics**: Order status breakdown and trends +- **Concierge Observability**: Review provider status, spend, latency, error/cache rates, intents, limits, and feedback ### 🔐 Security Features @@ -165,6 +168,7 @@ Fádé Essence is a complete e-commerce solution featuring: - **Paystack**: Payment processing - **Resend**: Email delivery service - **Vercel Analytics**: Web analytics +- **OpenAI, Anthropic, Google Gemini, or xAI**: Configurable Concierge V2 generation and hosted research providers ### Development Tools - **ESLint**: Code linting @@ -322,6 +326,11 @@ Fádé Essence is a complete e-commerce solution featuring: | `RESEND_API_KEY` | Resend API key | `re_...` | | `NEWSLETTER_FROM_EMAIL` | Email sender address | `noreply@yourdomain.com` | | `NEXT_PUBLIC_SITE_URL` | Public site URL | `https://yourdomain.com` | +| `AI_PROVIDER` | Concierge provider (`mock` is for development/test) | `openai` | +| `AI_PROVIDER_PRIORITY` | Ordered Concierge V2 provider fallback list | `openai,anthropic,gemini,xai` | +| `FEATURE_FLAGS` | Comma-separated feature switches; add `concierge_v2` to enable V2 | `concierge_v2` | +| `UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN` | Durable authenticated per-minute limits in production | Upstash credentials | +| `CONCIERGE_*` | Guest/auth limits, tool/search caps, token cap, spend gates, and catalogue-only mode | See `.env.example` | ## 📊 Database Schema @@ -355,6 +364,11 @@ Fádé Essence is a complete e-commerce solution featuring: - `GET /api/search?q=query` - Search products - `POST /api/newsletter/subscribe` - Subscribe to newsletter - `POST /api/contact` - Submit contact form +- `POST /api/v2/concierge/chat` - Stream a grounded Concierge V2 turn +- `GET /api/v2/concierge/allowance` - Read the current guest/authenticated allowance +- `GET|POST /api/v2/concierge/conversations` - List or create owned conversations +- `GET|PATCH|DELETE /api/v2/concierge/conversations/[id]` - Read, rename, or archive an owned conversation +- `POST /api/v2/concierge/messages/[id]/feedback` - Record assistant-response feedback ### Authenticated APIs @@ -403,6 +417,16 @@ The project uses a comprehensive set of reusable UI components built with Radix ## 🧪 Testing +Run the automated local gates with: + +```bash +npm run typecheck +npm run lint +npm test +npm run build +npx playwright test tests/e2e/concierge-v2.spec.ts +``` + ### Manual Testing Checklist - [ ] User registration and login @@ -460,6 +484,16 @@ The application can be deployed to any platform supporting Next.js: ## 📝 Development +### Concierge V2 documentation + +- [Architecture](docs/CONCIERGE_V2_ARCHITECTURE.md) +- [Provider matrix](docs/CONCIERGE_V2_PROVIDER_MATRIX.md) +- [Entitlements and limits](docs/CONCIERGE_V2_RATE_LIMITS.md) +- [Security boundaries](docs/CONCIERGE_V2_SECURITY.md) +- [Evaluation and preview test script](docs/CONCIERGE_V2_EVALUATION.md) +- [Deployment handoff and rollback](docs/CONCIERGE_V2_HANDOFF.md) +- [Live checklist](docs/CONCIERGE_V2_TODO.md) + ### Available Scripts - `npm run dev` - Start development server diff --git a/app/about/page.tsx b/app/about/page.tsx index 9eb4bb6..fd17030 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -67,7 +67,7 @@ export default function AboutPage() { key={principle.title} className="grid gap-4 border-b border-border py-8 sm:grid-cols-[3rem_1fr]" > - + {String(i + 1).padStart(2, "0")}
diff --git a/app/admin/concierge/page.tsx b/app/admin/concierge/page.tsx new file mode 100644 index 0000000..3e5a9e3 --- /dev/null +++ b/app/admin/concierge/page.tsx @@ -0,0 +1,59 @@ +import { prisma } from "@/lib/prisma" +import { env, integrationStatus } from "@/lib/env" +import { allFlags } from "@/lib/config/feature-flags" +import { conciergeProviderStatus } from "@/integrations/ai/router" +import { CONCIERGE_PRICING_VERIFIED_AT } from "@/lib/concierge/cost" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" + +export const dynamic = "force-dynamic" + +export default async function ConciergeAdminPage() { + const since = new Date(Date.now() - 24 * 60 * 60 * 1000) + const [conversations, usage, feedback, statuses, intents, cacheHits] = await Promise.all([ + prisma.conciergeConversation.count(), + prisma.conciergeUsageEvent.aggregate({ where: { createdAt: { gte: since } }, _count: true, _avg: { totalLatencyMs: true, firstTokenLatencyMs: true }, _sum: { inputTokens: true, outputTokens: true, estimatedCostMicros: true } }), + prisma.conciergeFeedback.groupBy({ by: ["rating"], _count: true }), + prisma.conciergeUsageEvent.groupBy({ by: ["completionStatus"], where: { createdAt: { gte: since } }, _count: true }), + prisma.conciergeUsageEvent.groupBy({ by: ["intent"], where: { createdAt: { gte: since } }, _count: true, orderBy: { _count: { intent: "desc" } }, take: 8 }), + prisma.conciergeUsageEvent.count({ where: { createdAt: { gte: since }, cacheStatus: "HIT" } }), + ]) + const flags = allFlags() + const configured = integrationStatus() + const providers = conciergeProviderStatus() + const failures = statuses.filter((status) => status.completionStatus !== "SUCCESS").reduce((sum, status) => sum + status._count, 0) + const errorRate = usage._count ? `${((failures / usage._count) * 100).toFixed(1)}%` : "0%" + const cacheRate = usage._count ? `${((cacheHits / usage._count) * 100).toFixed(1)}%` : "0%" + return ( +
+
+

Concierge V2

+

Provider health, usage, limits, and emergency controls. Secret values are never shown.

+
+
+ + + + + + + +
+ + Provider capability registry + + {providers.map((provider) => )}
ProviderConfiguredModelPriorityCapabilitiesCircuit
{provider.id}{provider.enabled ? "Yes" : "No"}{provider.model}{provider.priority}{provider.capabilities.join(", ")}{provider.circuits.some((c) => c.openUntil && c.openUntil > Date.now()) ? "Open" : "Healthy"}
+
+
+
+ Limits and cost controls + + + Usage, last 24 hours `${x.rating}: ${x._count}`).join(", ") || "None"} /> `${x.intent}: ${x._count}`).join(", ") || "None"} /> +
+

Change provider priority, limits, spend gates, catalogue-only mode, or the emergency kill switch through environment configuration and feature flags, then redeploy. API secret values remain server-only.

+
+ ) +} + +function Metric({ label, value }: { label: string; value: string }) { return

{label}

{value}

} +function Setting({ label, value }: { label: string; value: string | number }) { return
{label}{value}
} diff --git a/app/api/newsletter/test/route.ts b/app/api/newsletter/test/route.ts index c6f0df9..7c086c9 100644 --- a/app/api/newsletter/test/route.ts +++ b/app/api/newsletter/test/route.ts @@ -3,8 +3,6 @@ import { NextRequest, NextResponse } from 'next/server' import { auth } from '@/lib/auth' import { prisma } from '@/lib/prisma' -const resend = new Resend(process.env.RESEND_API_KEY) - /** Admin-only: send a test newsletter email. Recipient from body or env NEWSLETTER_TEST_EMAIL. */ export async function POST(req: NextRequest) { try { @@ -18,9 +16,11 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } - if (!process.env.RESEND_API_KEY) { + const resendKey = process.env.RESEND_API_KEY + if (!resendKey) { return NextResponse.json({ error: 'RESEND_API_KEY not set', success: false }, { status: 400 }) } + const resend = new Resend(resendKey) const body = await req.json().catch(() => ({})) const to = (body.to as string) || process.env.NEWSLETTER_TEST_EMAIL || email diff --git a/app/api/v2/concierge/allowance/route.ts b/app/api/v2/concierge/allowance/route.ts new file mode 100644 index 0000000..c5179bc --- /dev/null +++ b/app/api/v2/concierge/allowance/route.ts @@ -0,0 +1,13 @@ +import { route } from "@/lib/http/handler" +import { env } from "@/lib/env" +import { prisma } from "@/lib/prisma" +import { resolveConciergeIdentity } from "@/lib/concierge/conversation" + +export const runtime = "nodejs" +type AllowanceData = { authenticated: boolean; remaining: number | null; dailyLimit: number | null } +export const GET = route(async ({ req }) => { + const { identity } = await resolveConciergeIdentity(req) + if (identity.isAuthenticated) return { data: { authenticated: true, remaining: null, dailyLimit: env.CONCIERGE_AUTH_DAILY } } + const usage = await prisma.conciergeGuestAllowance.findUnique({ where: { guestKeyHash: identity.guestKeyHash! }, select: { successCount: true } }) + return { data: { authenticated: false, remaining: Math.max(0, env.CONCIERGE_GUEST_QUESTIONS - (usage?.successCount ?? 0)), dailyLimit: null } } +}) diff --git a/app/api/v2/concierge/chat/route.ts b/app/api/v2/concierge/chat/route.ts new file mode 100644 index 0000000..a31c182 --- /dev/null +++ b/app/api/v2/concierge/chat/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from "next/server" +import { z } from "zod" +import { isFeatureEnabled } from "@/lib/config/feature-flags" +import { ERROR_CATALOGUE, AppError, isAppError } from "@/lib/http/errors" +import { GUEST_COOKIE, resolveConciergeIdentity } from "@/lib/concierge/conversation" +import { routeConciergeIntent } from "@/lib/concierge/router" +import { orchestrateConciergeTurn } from "@/lib/concierge/orchestrator" +import { assertConciergeEntitlement, recordConciergeUsage } from "@/lib/concierge/usage" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +const Body = z.object({ message: z.string().trim().min(1).max(2000), conversationId: z.string().uuid().optional(), sampleFirst: z.boolean().optional() }) +const encode = (value: unknown) => new TextEncoder().encode(`${JSON.stringify(value)}\n`) + +export async function POST(req: NextRequest) { + const requestId = crypto.randomUUID() + if (!isFeatureEnabled("ai_concierge") || !isFeatureEnabled("concierge_v2")) return NextResponse.json({ data: null, error: { code: "FEATURE_DISABLED", message: ERROR_CATALOGUE.FEATURE_DISABLED.message }, meta: {}, requestId }, { status: 403 }) + let body: z.infer + let resolved: Awaited> + try { + body = Body.parse(await req.json()) + resolved = await resolveConciergeIdentity(req) + await assertConciergeEntitlement(req, resolved.identity, routeConciergeIntent(body.message).requiresWebResearch) + } catch (error) { + const appError = isAppError(error) ? error : error instanceof z.ZodError ? new AppError("VALIDATION_ERROR") : new AppError("INTERNAL_ERROR", { internal: error }) + return NextResponse.json({ data: null, error: { code: appError.code, message: appError.safeMessage }, meta: {}, requestId }, { status: appError.status }) + } + const startedAt = Date.now() + const stream = new ReadableStream({ + async start(controller) { + let closed = false + const send = (value: unknown) => { + if (closed || req.signal.aborted) return false + try { controller.enqueue(encode(value)); return true } + catch { closed = true; return false } + } + try { + send({ type: "status", requestId, message: "Starting perfume intelligence" }) + const result = await orchestrateConciergeTurn({ + requestId, identity: resolved.identity, ...body, signal: req.signal, + onStatus: (message) => send({ type: "status", message }), + onDelta: (delta) => { if (!send({ type: "delta", delta })) throw new AppError("SERVICE_UNAVAILABLE", { message: "Generation was cancelled." }) }, + }) + if (req.signal.aborted || closed) throw new AppError("SERVICE_UNAVAILABLE", { message: "Generation was cancelled." }) + if (result.products.length) send({ type: "products", products: result.products }) + if (result.sources.length) send({ type: "sources", sources: result.sources }) + send({ type: "done", result: { ...result, answer: undefined } }) + await recordConciergeUsage({ requestId, identity: resolved.identity, result, startedAt, completionStatus: "SUCCESS" }) + } catch (error) { + const appError = isAppError(error) ? error : new AppError("INTERNAL_ERROR", { internal: error }) + send({ type: "error", error: { code: appError.code, message: appError.safeMessage, retryable: appError.status >= 500 } }) + await recordConciergeUsage({ requestId, identity: resolved.identity, startedAt, completionStatus: req.signal.aborted ? "CANCELLED" : "FAILED", errorCode: appError.code }) + } finally { if (!closed) { try { controller.close() } catch { /* Client disconnected. */ } } } + }, + }) + const response = new NextResponse(stream, { status: 200, headers: { "content-type": "application/x-ndjson; charset=utf-8", "cache-control": "no-store", "x-request-id": requestId, "x-content-type-options": "nosniff" } }) + if (resolved.newGuestToken) response.cookies.set(GUEST_COOKIE, resolved.newGuestToken, { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/", maxAge: 60 * 60 * 24 * 365 }) + return response +} diff --git a/app/api/v2/concierge/conversations/[id]/route.ts b/app/api/v2/concierge/conversations/[id]/route.ts new file mode 100644 index 0000000..f9c4d20 --- /dev/null +++ b/app/api/v2/concierge/conversations/[id]/route.ts @@ -0,0 +1,34 @@ +import { z } from "zod" +import { route } from "@/lib/http/handler" +import { resolveConciergeIdentity, loadOwnedConversation, renameOwnedConversation, archiveOwnedConversation } from "@/lib/concierge/conversation" +import { getFadeProductsBatch } from "@/lib/concierge/tools/catalogue" + +export const runtime = "nodejs" +const Body = z.object({ title: z.string().trim().min(1).max(100) }) + +export const GET = route<{ conversation: unknown | null }>(async ({ req }) => { + const id = req.nextUrl.pathname.split("/").at(-1)! + const { identity } = await resolveConciergeIdentity(req) + const conversation = await loadOwnedConversation(id, identity) + if (!conversation) return { data: { conversation: null } } + const ids = [...new Set(conversation.messages.flatMap((message) => Array.isArray(message.productRefs) ? message.productRefs.filter((value): value is string => typeof value === "string") : []))].slice(0, 20) + const catalogue = ids.length ? await getFadeProductsBatch(ids) : { products: [] } + const byId = new Map(catalogue.products.map((product) => [product.id, product])) + return { data: { conversation: { ...conversation, messages: conversation.messages.map((message) => ({ + ...message, + products: Array.isArray(message.productRefs) ? message.productRefs.flatMap((value) => typeof value === "string" && byId.has(value) ? [byId.get(value)!] : []) : [], + })) } } } +}) +export const PATCH = route(async ({ req }) => { + const id = req.nextUrl.pathname.split("/").at(-1)! + const { identity } = await resolveConciergeIdentity(req) + const { title } = Body.parse(await req.json()) + await renameOwnedConversation(id, identity, title) + return { data: { id, title } } +}) +export const DELETE = route(async ({ req }) => { + const id = req.nextUrl.pathname.split("/").at(-1)! + const { identity } = await resolveConciergeIdentity(req) + await archiveOwnedConversation(id, identity) + return { data: { id, archived: true } } +}) diff --git a/app/api/v2/concierge/conversations/route.ts b/app/api/v2/concierge/conversations/route.ts new file mode 100644 index 0000000..c8ccc8d --- /dev/null +++ b/app/api/v2/concierge/conversations/route.ts @@ -0,0 +1,20 @@ +import { z } from "zod" +import { route } from "@/lib/http/handler" +import { resolveConciergeIdentity, listOwnedConversations, createOwnedConversation, guestCookieHeader } from "@/lib/concierge/conversation" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +export const GET = route(async ({ req }) => { + const { identity } = await resolveConciergeIdentity(req) + const conversations = await listOwnedConversations(identity, req.nextUrl.searchParams.get("q") ?? undefined) + return { data: { conversations } } +}) + +const Body = z.object({ title: z.string().trim().min(1).max(100).optional() }) +export const POST = route(async ({ req }) => { + const { identity, newGuestToken } = await resolveConciergeIdentity(req) + const body = Body.parse(await req.json()) + const conversation = await createOwnedConversation(identity, body.title) + return { data: { conversation }, status: 201, ...(newGuestToken ? { headers: { "set-cookie": guestCookieHeader(newGuestToken) } } : {}) } +}) diff --git a/app/api/v2/concierge/messages/[id]/feedback/route.ts b/app/api/v2/concierge/messages/[id]/feedback/route.ts new file mode 100644 index 0000000..ba0d079 --- /dev/null +++ b/app/api/v2/concierge/messages/[id]/feedback/route.ts @@ -0,0 +1,15 @@ +import { z } from "zod" +import { route, raise } from "@/lib/http/handler" +import { prisma } from "@/lib/prisma" +import { resolveConciergeIdentity } from "@/lib/concierge/conversation" + +const Body = z.object({ rating: z.enum(["HELPFUL", "NOT_HELPFUL", "REPORTED"]), reason: z.string().trim().max(500).optional() }) +export const POST = route(async ({ req }) => { + const messageId = req.nextUrl.pathname.split("/").at(-2)! + const { identity } = await resolveConciergeIdentity(req) + const message = await prisma.conciergeMessage.findFirst({ where: { id: messageId, role: "assistant", conversation: identity.userId ? { userId: identity.userId } : { guestKeyHash: identity.guestKeyHash! } }, select: { id: true } }) + if (!message) raise("NOT_FOUND") + const body = Body.parse(await req.json()) + const feedback = await prisma.conciergeFeedback.upsert({ where: { messageId }, create: { messageId, userId: identity.userId, guestKeyHash: identity.guestKeyHash, rating: body.rating, reportReason: body.reason }, update: { rating: body.rating, reportReason: body.reason } }) + return { data: { feedback: { id: feedback.id, rating: feedback.rating } } } +}) diff --git a/app/concierge/page.tsx b/app/concierge/page.tsx index 3ce884c..2124c49 100644 --- a/app/concierge/page.tsx +++ b/app/concierge/page.tsx @@ -5,7 +5,7 @@ import { ConciergeClient } from "@/components/concierge/concierge-client" export const metadata: Metadata = { title: "AI Scent Concierge", description: - "Describe the mood, occasion and notes you love, and the Fádé Scent Concierge recommends real, in-stock fragrances from our catalogue.", + "Ask Fádé Perfume Intelligence about notes, accords, perfume technique, climate, comparisons, current research, and live catalogue availability.", } export default function ConciergePage() { diff --git a/app/globals.css b/app/globals.css index 049887d..f6524cc 100644 --- a/app/globals.css +++ b/app/globals.css @@ -967,3 +967,16 @@ .animate-accordion-up { animation: accordion-up 0.2s ease-out; } + +@media (max-width: 1023px) { + aside.\-translate-x-full { + visibility: hidden; + } +} + +@media (prefers-reduced-motion: reduce) { + [data-reduced-motion-safe] { + animation: none !important; + transition-duration: 0ms !important; + } +} diff --git a/app/page.tsx b/app/page.tsx index d6acd1f..3ca3bea 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,8 +1,6 @@ import { MainLayout } from "@/components/layout/main-layout" -import { HeroSection } from "@/components/home/hero/hero-section" - -import { HeroOrbitSection } from "@/components/home/hero/orbit/hero-orbit-section" +import { EditorialHeroSection } from "@/components/home/hero/editorial/editorial-hero-section" import { FeaturedProductsSection } from "@/components/home/featured-products-section" @@ -13,21 +11,10 @@ import { BrandStorySection } from "@/components/home/brand-story-section" import { ConciergeInvitation } from "@/components/home/concierge-invitation" import { prisma } from "@/lib/prisma" -import { selectHeroFeaturedProduct } from "@/lib/hero/select" -import { selectHeroOrbit } from "@/lib/hero/orbit" -import { isFeatureEnabled } from "@/lib/config/feature-flags" export const dynamic = "force-dynamic" export default async function HomePage() { - // Stage 2 orbital showcase: only behind the hero_orbit flag AND with >=2 approved slides. - // Anything less falls straight back to the approved Stage 1 hero below. - const orbitData = isFeatureEnabled("hero_orbit") ? await selectHeroOrbit() : null - - // Merchant-approved hero fragrance (published + featured + owned image), or null for the neutral - // placeholder. Never fabricates a bottle or perfume information. - const heroData = orbitData ? null : await selectHeroFeaturedProduct() - // Fetch featured products from database (best-effort; allow the page to render even if DB isn't ready). let dbProducts: Awaited> = [] try { @@ -91,7 +78,7 @@ export default async function HomePage() { - {orbitData ? : } + diff --git a/components/admin/admin-sidebar.tsx b/components/admin/admin-sidebar.tsx index fb7b9c7..74aa62a 100644 --- a/components/admin/admin-sidebar.tsx +++ b/components/admin/admin-sidebar.tsx @@ -3,7 +3,7 @@ import * as React from "react" import Link from "next/link" import { usePathname } from "next/navigation" -import { LayoutDashboard, Package, ShoppingCart, Tags, Folder, ChevronLeft, Menu, LogOut, Mail, Warehouse } from "lucide-react" +import { LayoutDashboard, Package, ShoppingCart, Tags, Folder, ChevronLeft, Menu, LogOut, Mail, Warehouse, Bot } from "lucide-react" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" @@ -18,6 +18,7 @@ const navItems = [ { name: "Orders", href: "/admin/orders", icon: ShoppingCart }, { name: "Inventory", href: "/admin/inventory", icon: Warehouse }, { name: "Newsletter", href: "/admin/newsletter", icon: Mail }, + { name: "Concierge V2", href: "/admin/concierge", icon: Bot }, ] function SidebarContent() { diff --git a/components/concierge/concierge-client.tsx b/components/concierge/concierge-client.tsx index 4ab54af..f2e8548 100644 --- a/components/concierge/concierge-client.tsx +++ b/components/concierge/concierge-client.tsx @@ -1,347 +1,138 @@ "use client" import * as React from "react" -import Link from "next/link" import Image from "next/image" -import { Sparkles, ArrowUp, RotateCcw, ArrowRight } from "lucide-react" -import { cn } from "@/lib/utils" +import Link from "next/link" +import { ArrowUp, Bot, Check, Copy, History, Menu, MessageSquarePlus, RotateCcw, Search, Square, ThumbsDown, ThumbsUp, X } from "lucide-react" import { Button } from "@/components/ui/button" -import { LogoMark } from "@/components/logo" - -/** Product shape returned by the recommendation engine (subset the UI needs). */ -interface ConciergeProduct { - id: string - slug: string - name: string - brand: string | null - price: { amountNGN: number } - images: string[] -} - -interface ConciergeItem { - product: ConciergeProduct - reasons: string[] - availability: "in_stock" | "preorder" | "waitlist" -} - -interface AssistantTurn { - role: "assistant" - text: string - items: ConciergeItem[] - disclaimer?: string -} -interface UserTurn { - role: "user" - text: string -} -type Turn = UserTurn | AssistantTurn - -const SUGGESTED_PROMPTS = [ - "A warm oud for Lagos evenings", - "Something clean and fresh for the office", - "Vanilla, but not too sweet", - "A romantic scent under ₦200,000", -] +import { cn } from "@/lib/utils" +import type { ConciergeProductCard, ConciergeSource } from "@/lib/concierge/types" -const AVAILABILITY_LABEL: Record = { - in_stock: "In stock", - preorder: "Pre-order", - waitlist: "Waitlist", -} +interface Turn { id?: string; role: "user" | "assistant"; text: string; products?: ConciergeProductCard[]; sources?: ConciergeSource[]; status?: string; error?: { code: string; message: string } } +interface ConversationSummary { id: string; title: string | null; updatedAt: string; _count: { messages: number } } -function formatNGN(amount: number) { - return new Intl.NumberFormat("en-NG", { - style: "currency", - currency: "NGN", - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(amount) -} +const PROMPTS = ["What does bergamot smell like?", "What works for a rainy-season office?", "Compare oud and sandalwood.", "Which Fádé scents are currently available?"] +const money = (amount: number) => new Intl.NumberFormat("en-NG", { style: "currency", currency: "NGN", maximumFractionDigits: 0 }).format(amount) export function ConciergeClient() { const [turns, setTurns] = React.useState([]) + const [conversations, setConversations] = React.useState([]) + const [conversationId, setConversationId] = React.useState() const [input, setInput] = React.useState("") const [sampleFirst, setSampleFirst] = React.useState(false) - const [status, setStatus] = React.useState<"idle" | "loading" | "error" | "unavailable">("idle") - const [lastQuery, setLastQuery] = React.useState("") + const [running, setRunning] = React.useState(false) + const [historyOpen, setHistoryOpen] = React.useState(false) + const [historyQuery, setHistoryQuery] = React.useState("") + const [allowance, setAllowance] = React.useState<{ authenticated: boolean; remaining: number | null }>() + const [copied, setCopied] = React.useState() + const abortRef = React.useRef(null) const scrollRef = React.useRef(null) + const textRef = React.useRef(null) + + const loadSidebar = React.useCallback(async () => { + const [historyRes, allowanceRes] = await Promise.all([fetch(`/api/v2/concierge/conversations${historyQuery ? `?q=${encodeURIComponent(historyQuery)}` : ""}`), fetch("/api/v2/concierge/allowance")]) + if (historyRes.ok) setConversations((await historyRes.json()).data?.conversations ?? []) + if (allowanceRes.ok) setAllowance((await allowanceRes.json()).data) + }, [historyQuery]) + + React.useEffect(() => { void loadSidebar() }, [loadSidebar]) + React.useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }) }, [turns, running]) + React.useEffect(() => { const el = textRef.current; if (el) { el.style.height = "0px"; el.style.height = `${Math.min(el.scrollHeight, 176)}px` } }, [input]) + React.useEffect(() => () => abortRef.current?.abort(), []) + + const newChat = () => { abortRef.current?.abort(); setConversationId(undefined); setTurns([]); setInput(""); setHistoryOpen(false) } + + const openConversation = async (id: string) => { + if (running) return + const response = await fetch(`/api/v2/concierge/conversations/${id}`) + const json = await response.json() + if (!response.ok || !json.data?.conversation) return + const conversation = json.data.conversation + setConversationId(id) + setTurns((conversation.messages ?? []).map((message: any) => ({ id: message.id, role: message.role, text: message.content, sources: Array.isArray(message.sources) ? message.sources : [], products: Array.isArray(message.products) ? message.products : [] }))) + setHistoryOpen(false) + } - React.useEffect(() => { - scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }) - }, [turns, status]) - - const ask = React.useCallback( - async (message: string) => { - const trimmed = message.trim() - if (!trimmed || status === "loading") return - - setTurns((prev) => [...prev, { role: "user", text: trimmed }]) - setInput("") - setLastQuery(trimmed) - setStatus("loading") - - try { - const res = await fetch("/api/v1/concierge", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: trimmed, sampleFirst, limit: 6 }), - }) - const json = await res.json() - - if (!res.ok || json?.error) { - if (json?.error?.code === "FEATURE_DISABLED") { - setStatus("unavailable") - return - } - setStatus("error") - return + const ask = React.useCallback(async (raw: string) => { + const message = raw.trim() + if (!message || running) return + const controller = new AbortController(); abortRef.current = controller + setInput(""); setRunning(true) + setTurns((previous) => [...previous, { role: "user", text: message }, { role: "assistant", text: "", status: "Understanding your question" }]) + try { + const response = await fetch("/api/v2/concierge/chat", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ message, conversationId, sampleFirst }), signal: controller.signal }) + if (!response.ok || !response.body) { + const json = await response.json().catch(() => null) + throw Object.assign(new Error(json?.error?.message ?? "The concierge is unavailable."), { code: json?.error?.code ?? "SERVICE_UNAVAILABLE" }) + } + const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = "" + while (true) { + const { value, done } = await reader.read(); if (done) break + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split("\n"); buffer = lines.pop() ?? "" + for (const line of lines) { + if (!line.trim()) continue + const event = JSON.parse(line) + setTurns((previous) => previous.map((turn, index) => index !== previous.length - 1 ? turn : event.type === "status" ? { ...turn, status: event.message } + : event.type === "delta" ? { ...turn, text: turn.text + event.delta, status: undefined } + : event.type === "products" ? { ...turn, products: event.products } + : event.type === "sources" ? { ...turn, sources: event.sources } + : event.type === "done" ? { ...turn, id: event.result.messageId, status: undefined } + : event.type === "error" ? { ...turn, status: undefined, error: event.error } + : turn)) + if (event.type === "done") setConversationId(event.result.conversationId) } - - const data = json.data - setTurns((prev) => [ - ...prev, - { - role: "assistant", - text: data.message ?? "Here are some matches.", - items: (data.items ?? []) as ConciergeItem[], - disclaimer: data.disclaimer, - }, - ]) - setStatus("idle") - } catch { - setStatus("error") } - }, - [sampleFirst, status], - ) + await loadSidebar() + } catch (error) { + if ((error as Error).name !== "AbortError") setTurns((previous) => previous.map((turn, index) => index === previous.length - 1 ? { ...turn, status: undefined, error: { code: (error as any).code ?? "SERVICE_UNAVAILABLE", message: (error as Error).message } } : turn)) + } finally { setRunning(false); abortRef.current = null } + }, [conversationId, loadSidebar, running, sampleFirst]) + const contextualTurn = [...turns].reverse().find((turn) => turn.role === "assistant" && ((turn.products?.length ?? 0) || (turn.sources?.length ?? 0))) const isEmpty = turns.length === 0 - - const composer = ( -
{ - e.preventDefault() - ask(input) - }} - className="w-full" - > -
-