Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
Expand All @@ -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=""

Expand All @@ -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"
51 changes: 50 additions & 1 deletion .github/workflows/storefront-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/about/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]"
>
<span className="font-mono text-[11px] tracking-[0.2em] text-muted-foreground/60">
<span className="font-mono text-[11px] tracking-[0.2em] text-muted-foreground">
{String(i + 1).padStart(2, "0")}
</span>
<div>
Expand Down
59 changes: 59 additions & 0 deletions app/admin/concierge/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-6">
<div>
<h1 className="font-serif text-3xl font-semibold">Concierge V2</h1>
<p className="mt-1 text-sm text-muted-foreground">Provider health, usage, limits, and emergency controls. Secret values are never shown.</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<Metric label="Feature enabled" value={flags.concierge_v2 ? "Yes" : "No"} />
<Metric label="Conversations" value={String(conversations)} />
<Metric label="Turns, last 24h" value={String(usage._count)} />
<Metric label="Average latency" value={`${Math.round(usage._avg.totalLatencyMs ?? 0)} ms`} />
<Metric label="Average first token" value={`${Math.round(usage._avg.firstTokenLatencyMs ?? 0)} ms`} />
<Metric label="Error rate" value={errorRate} />
<Metric label="Cache hit rate" value={cacheRate} />
</div>
<Card>
<CardHeader><CardTitle>Provider capability registry</CardTitle></CardHeader>
<CardContent className="overflow-x-auto">
<table className="w-full min-w-[760px] text-sm"><thead><tr className="border-b text-left text-muted-foreground"><th className="py-2">Provider</th><th>Configured</th><th>Model</th><th>Priority</th><th>Capabilities</th><th>Circuit</th></tr></thead><tbody>{providers.map((provider) => <tr key={provider.id} className="border-b last:border-0"><td className="py-3 font-medium capitalize">{provider.id}</td><td>{provider.enabled ? "Yes" : "No"}</td><td>{provider.model}</td><td>{provider.priority}</td><td className="max-w-md">{provider.capabilities.join(", ")}</td><td>{provider.circuits.some((c) => c.openUntil && c.openUntil > Date.now()) ? "Open" : "Healthy"}</td></tr>)}</tbody></table>
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-2">
<Card><CardHeader><CardTitle>Limits and cost controls</CardTitle></CardHeader><CardContent className="grid gap-2 text-sm sm:grid-cols-2">
<Setting label="Guest questions" value={env.CONCIERGE_GUEST_QUESTIONS} /><Setting label="Authenticated per minute" value={env.CONCIERGE_AUTH_PER_MINUTE} /><Setting label="Authenticated daily" value={env.CONCIERGE_AUTH_DAILY} /><Setting label="Web searches daily" value={env.CONCIERGE_WEB_DAILY} /><Setting label="Max tool calls" value={env.CONCIERGE_MAX_TOOL_CALLS} /><Setting label="Max search calls" value={env.CONCIERGE_MAX_SEARCH_CALLS} /><Setting label="Max output tokens" value={env.CONCIERGE_MAX_OUTPUT_TOKENS} /><Setting label="Daily spend limit" value={`$${env.CONCIERGE_DAILY_SPEND_USD}`} /><Setting label="Monthly spend limit" value={`$${env.CONCIERGE_MONTHLY_SPEND_USD}`} /><Setting label="Catalogue-only fallback" value={env.CONCIERGE_CATALOGUE_ONLY ? "On" : "Off"} />
</CardContent></Card>
<Card><CardHeader><CardTitle>Usage, last 24 hours</CardTitle></CardHeader><CardContent className="grid gap-2 text-sm sm:grid-cols-2"><Setting label="Input tokens" value={usage._sum.inputTokens ?? 0} /><Setting label="Output tokens" value={usage._sum.outputTokens ?? 0} /><Setting label="Estimated spend" value={`$${((usage._sum.estimatedCostMicros ?? 0) / 1_000_000).toFixed(2)}`} /><Setting label="Pricing verified" value={CONCIERGE_PRICING_VERIFIED_AT} /><Setting label="Configured AI selector" value={configured.ai} /><Setting label="Feedback" value={feedback.map((x) => `${x.rating}: ${x._count}`).join(", ") || "None"} /><Setting label="Top intents" value={intents.map((x) => `${x.intent}: ${x._count}`).join(", ") || "None"} /></CardContent></Card>
</div>
<p className="text-xs text-muted-foreground">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.</p>
</div>
)
}

function Metric({ label, value }: { label: string; value: string }) { return <Card><CardContent className="pt-6"><p className="text-xs uppercase tracking-wider text-muted-foreground">{label}</p><p className="mt-2 text-2xl font-semibold">{value}</p></CardContent></Card> }
function Setting({ label, value }: { label: string; value: string | number }) { return <div className="flex justify-between gap-3 rounded-lg bg-muted/50 px-3 py-2"><span className="text-muted-foreground">{label}</span><span className="font-medium">{value}</span></div> }
6 changes: 3 additions & 3 deletions app/api/newsletter/test/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
13 changes: 13 additions & 0 deletions app/api/v2/concierge/allowance/route.ts
Original file line number Diff line number Diff line change
@@ -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<AllowanceData>(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 } }
})
Loading
Loading