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
110 changes: 93 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,28 +1,61 @@
# Tai Manic Studios
# F.A.T.U / Tai Manic Studios

Art-first commerce platform for publishing original artwork across approved garment configurations.
An art-first commerce platform for publishing original artwork across approved garment configurations, selling finished designs, and operating the workflow through a dedicated administration system.

## Workspace
> **Status:** Active collaborative development. The repository contains a storefront, admin application, modular API, background worker, shared packages, and infrastructure definitions.

![F.A.T.U storefront at desktop width](./apps/storefront/tests/__screenshots__/visual/showcase.spec.ts/home-desktop-1440.png)

## Product model

This repository is a pnpm/Turborepo monorepo. The backend is a modular TypeScript system with a NestJS API, a background worker, PostgreSQL/Prisma persistence, Redis/BullMQ jobs, and S3-compatible storage.
Artwork is the primary creative entity. Garments are approved canvases for immutable artwork versions—not the root catalogue entity.

The platform supports:

- editorial artwork and garment discovery;
- front/back garment configuration and artwork placement;
- saved designs and stable sharing;
- carts, promotions, checkout, payment-provider abstraction, and order snapshots;
- inventory reservations and append-only stock movement;
- customer identity and account workflows;
- administration, media ingestion, derivative processing, and operational review;
- a customer-facing concierge with configurable provider fallback.

## Workspace

```text
apps/api Versioned HTTP API and OpenAPI document
apps/worker Background processing entry point
apps/storefront Frontend-owned storefront location
apps/admin Frontend-owned administration location
packages/* Shared contracts and platform packages
infra Local infrastructure
docs Product, backend, contract, and coordination records
apps/api Versioned NestJS API and OpenAPI document
apps/worker BullMQ background-processing entry point
apps/storefront Next.js customer storefront and Design Studio
apps/admin Next.js administration application
packages/* Contracts, database, UI, media, email, and shared tooling
infra Local PostgreSQL, Redis, and object-storage infrastructure
docs Product, testing, contract, coordination, and handoff records
```

## Prerequisites
OpenAPI is the backend-to-frontend source of truth. Shared contracts, frontend integrations, and backend handlers must stay aligned.

## Stack

| Layer | Technology |
| --------- | --------------------------------------------------------------- |
| Workspace | pnpm, Turborepo, TypeScript |
| Frontend | Next.js 16, React 19, Tailwind CSS |
| API | NestJS 11, OpenAPI, class-validator, Helmet |
| Data | PostgreSQL, Prisma |
| Jobs | Redis, BullMQ |
| Media | S3-compatible storage, Sharp |
| Quality | ESLint, Prettier, Vitest, Playwright, Testing Library, axe-core |

## Local setup

### Prerequisites

- Node.js 22.18 or newer
- pnpm 10.20 or newer
- Docker with Compose for local services
- Docker with Compose

## Quick start
### Install and run

```bash
cp .env.example .env
Expand All @@ -33,8 +66,51 @@ pnpm build
pnpm dev
```

The API listens on `http://localhost:4000`, with OpenAPI UI at `/api/docs` and liveness/readiness under `/api/v1/health`.
Default local services:

- Storefront: `http://localhost:3000`
- Admin: `http://localhost:3001`
- API: `http://localhost:4000`
- OpenAPI: `http://localhost:4000/api/docs`
- Health: `http://localhost:4000/api/v1/health`

Use [`.env.example`](./.env.example) as the variable-name reference. Never commit real database, Redis, storage, email, payment, or AI-provider credentials.

## Commands

| Command | Purpose |
| ------------------- | ---------------------------------------- |
| `pnpm dev` | Start workspace services in parallel |
| `pnpm build` | Build all packages and applications |
| `pnpm format:check` | Verify formatting |
| `pnpm lint` | Run workspace lint tasks |
| `pnpm typecheck` | Run TypeScript checks |
| `pnpm test` | Run unit and integration tests |
| `pnpm db:validate` | Validate the Prisma schema |
| `pnpm check` | Run the complete repository quality gate |

The storefront also exposes its Playwright suite through the package-specific scripts documented in [`apps/storefront/README.md`](./apps/storefront/README.md).

## Documentation map

- [`docs/backend/TESTING.md`](./docs/backend/TESTING.md) — backend verification
- [`docs/backend/SECURITY.md`](./docs/backend/SECURITY.md) — security model and expectations
- [`docs/frontend/README.md`](./docs/frontend/README.md) — frontend guidance
- [`docs/coordination/`](./docs/coordination/) — cross-boundary contract notes
- [`docs/handoffs/`](./docs/handoffs/) — implementation handoffs
- [`AGENTS.md`](./AGENTS.md) — repository ownership and completion rules

## Security and operations

- Keep price, availability, inventory, and order state server-authoritative.
- Validate media type, size, and ownership before ingestion; store derivatives under controlled keys.
- Protect admin routes with explicit role checks and audited mutations.
- Treat payment callbacks as untrusted until signature and idempotency checks pass.
- Do not log credentials, payment payloads, private customer data, or raw provider responses.
- Apply reviewed migrations and validate backup/restore behavior before production rollout.

## Contribution context and licence

## Quality checks
This is a collaborative repository. Application areas have explicit ownership recorded in [`AGENTS.md`](./AGENTS.md); use commit and pull-request history when describing individual contributions.

Run `pnpm check` before opening a pull request. See `docs/backend/TESTING.md` and `AGENTS.md` for ownership and coordination rules.
No open-source licence is currently granted. Public visibility does not by itself permit reuse, modification, or redistribution.
7 changes: 1 addition & 6 deletions apps/api/src/concierge/concierge.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,7 @@ import {
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import {
ApiExtraModels,
ApiOkResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { ApiExtraModels, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { ApiResponse } from '@tms/contracts';
import type { Request } from 'express';

Expand Down
5 changes: 1 addition & 4 deletions apps/api/src/concierge/concierge.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,7 @@ export class ConciergeService {
intent?: string;
pagePath?: string;
}) {
const conversation = await this.ensureConversation(
input.conversationPublicId,
input.pagePath,
);
const conversation = await this.ensureConversation(input.conversationPublicId, input.pagePath);
if (input.intent) {
await this.database.client.aiConversation.update({
where: { id: conversation.id },
Expand Down
7 changes: 6 additions & 1 deletion apps/storefront/app/api/concierge/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ function clientKey(request: Request): string {
export async function POST(request: Request): Promise<Response> {
if (process.env.AI_ENABLED === 'false') {
return Response.json(
{ error: { code: 'INTEGRATION_UNAVAILABLE', message: 'The Concierge is temporarily unavailable.' } },
{
error: {
code: 'INTEGRATION_UNAVAILABLE',
message: 'The Concierge is temporarily unavailable.',
},
},
{ status: 503 },
);
}
Expand Down
4 changes: 3 additions & 1 deletion apps/storefront/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ export default async function HomePage() {
// Prefer the curated order first, then fill from the catalogue — one plate per slide, no dupes.
for (const slug of [
...HERO_SLUG_ORDER,
...artworks.map((a) => a.slug).filter((s) => !(HERO_SLUG_ORDER as readonly string[]).includes(s)),
...artworks
.map((a) => a.slug)
.filter((s) => !(HERO_SLUG_ORDER as readonly string[]).includes(s)),
]) {
if (heroSlides.length >= PILLARS.length) break;
if (HERO_EXCLUDED_SLUGS.has(slug)) continue;
Expand Down
7 changes: 3 additions & 4 deletions apps/storefront/app/studio-guide/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,9 @@ export default function StudioGuidePage() {
Studio Guide
</Heading>
<Text size="lg" tone="secondary" className="mt-3">
The site-wide{' '}
<strong className="font-medium text-ink">F.A.T.U Concierge</strong> (launcher on every
page) is the primary assistant. This page keeps the original Studio Guide preview chat.
For a specific order, our{' '}
The site-wide <strong className="font-medium text-ink">F.A.T.U Concierge</strong>{' '}
(launcher on every page) is the primary assistant. This page keeps the original Studio
Guide preview chat. For a specific order, our{' '}
<Link
href="/contact"
className="rounded-sm text-accent underline underline-offset-2 hover:text-ink"
Expand Down
5 changes: 4 additions & 1 deletion apps/storefront/components/concierge/concierge-launcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ export function ConciergeLauncher() {
<BrandLogo className="size-8 rounded-full" alt="" sizes="32px" />
<span className="hidden max-w-[10rem] truncate sm:inline">{assistantName}</span>
{panel === 'minimised' ? (
<span className="absolute -right-0.5 -top-0.5 size-2.5 rounded-full bg-accent" aria-hidden />
<span
className="absolute -right-0.5 -top-0.5 size-2.5 rounded-full bg-accent"
aria-hidden
/>
) : null}
</button>
);
Expand Down
34 changes: 11 additions & 23 deletions apps/storefront/components/concierge/concierge-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
'use client';

import { buttonVariants, cn, Price } from '@tms/ui';
import {
ArrowRight,
Minimize2,
RotateCw,
Send,
ShieldCheck,
X,
} from 'lucide-react';
import { ArrowRight, Minimize2, RotateCw, Send, ShieldCheck, X } from 'lucide-react';
import Link from 'next/link';
import { useEffect, useId, useRef } from 'react';
import { BrandLogo } from '@/components/site/brand-logo';
Expand Down Expand Up @@ -71,12 +64,7 @@ export function ConciergePanel() {
)}
>
<header className="flex items-start gap-3 border-b border-line bg-canvas-2 p-4">
<BrandLogo
className="size-10 shrink-0 rounded-full"
alt=""
sizes="40px"
priority
/>
<BrandLogo className="size-10 shrink-0 rounded-full" alt="" sizes="40px" priority />
<div className="min-w-0 flex-1">
<p className="truncate font-display text-lg text-ink">{assistantName}</p>
<p className="text-sm text-muted">
Expand Down Expand Up @@ -131,7 +119,10 @@ export function ConciergePanel() {
)}

{pending ? (
<div className="flex items-center gap-2 text-sm text-muted" aria-label="Concierge is responding">
<div
className="flex items-center gap-2 text-sm text-muted"
aria-label="Concierge is responding"
>
<BrandLogo className="size-4 animate-pulse rounded-full" alt="" sizes="16px" />
<span>Working…</span>
</div>
Expand All @@ -158,13 +149,7 @@ export function ConciergePanel() {
);
}

function AssistantBubble({
message,
onRetry,
}: {
message: ConciergeMessage;
onRetry: () => void;
}) {
function AssistantBubble({ message, onRetry }: { message: ConciergeMessage; onRetry: () => void }) {
return (
<div className="max-w-[92%]">
<div className="rounded-[var(--radius-md)] rounded-bl-sm bg-canvas-2 px-3 py-2.5 text-sm whitespace-pre-wrap text-ink">
Expand Down Expand Up @@ -195,7 +180,10 @@ function AssistantBubble({
</p>
) : null}
</div>
<ArrowRight className="mt-0.5 size-4 shrink-0 text-ink group-hover:translate-x-0.5" aria-hidden />
<ArrowRight
className="mt-0.5 size-4 shrink-0 text-ink group-hover:translate-x-0.5"
aria-hidden
/>
</Link>
</li>
))}
Expand Down
3 changes: 1 addition & 2 deletions apps/storefront/components/concierge/concierge-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,7 @@ export function ConciergeProvider({

const pageContext = useMemo<ConciergePageContext>(() => {
const pageType = pageTypeFromPath(pathname);
const artworkSlug =
pageType === 'artwork' ? pathname.split('/')[2]?.split('?')[0] : undefined;
const artworkSlug = pageType === 'artwork' ? pathname.split('/')[2]?.split('?')[0] : undefined;
const collectionSlug =
pageType === 'collection' ? pathname.split('/')[2]?.split('?')[0] : undefined;
return {
Expand Down
6 changes: 1 addition & 5 deletions apps/storefront/components/product/product-configurator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,7 @@ export function ProductConfigurator({ product }: { product: ProductDetail }) {
garment={product.garment}
colour={colour ?? selectedColour?.hex}
view={view}
artwork={
print && view === 'front'
? { src: print, area: 'front', alt: '' }
: null
}
artwork={print && view === 'front' ? { src: print, area: 'front', alt: '' } : null}
priority={view === 'front'}
className="p-4 sm:p-6"
sizes="(min-width: 1024px) 40vw, 90vw"
Expand Down
3 changes: 2 additions & 1 deletion apps/storefront/components/studio/design-studio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ export function DesignStudio({
);
const artworkFolders = useMemo(() => groupArtworksForStudio(artworks), [artworks]);
const [openFolders, setOpenFolders] = useState<Set<StudioArtworkFolder['id']>>(
() => new Set([defaultOpenStudioFolderId(groupArtworksForStudio(artworks), initialConfig.artwork)]),
() =>
new Set([defaultOpenStudioFolderId(groupArtworksForStudio(artworks), initialConfig.artwork)]),
);
const [copied, setCopied] = useState(false);
const [status, setStatus] = useState<string | null>(null);
Expand Down
16 changes: 13 additions & 3 deletions apps/storefront/lib/concierge/intent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ export function routeIntent(message: string, context?: ConciergePageContext): In
}
}

if (/\b(human|agent|someone|real person|speak to|talk to (a |the )?person|escalat)\b/.test(text)) {
if (
/\b(human|agent|someone|real person|speak to|talk to (a |the )?person|escalat)\b/.test(text)
) {
return {
intent: 'human_handoff',
allowedTools: [TOOLS.escalate, TOOLS.ticket],
Expand All @@ -67,7 +69,9 @@ export function routeIntent(message: string, context?: ConciergePageContext): In
};
}

if (/\b(my order|order status|where.*(order)|track|tracking|order number|order ref)\b/.test(text)) {
if (
/\b(my order|order status|where.*(order)|track|tracking|order number|order ref)\b/.test(text)
) {
return {
intent: 'order_support',
allowedTools: [TOOLS.orders, TOOLS.orderStatus, TOOLS.ticket, TOOLS.knowledge],
Expand Down Expand Up @@ -98,7 +102,13 @@ export function routeIntent(message: string, context?: ConciergePageContext): In
if (/\b(design studio|placement|compatible|mockup|customi[sz]e)\b/.test(text)) {
return {
intent: 'design_studio',
allowedTools: [TOOLS.studio, TOOLS.validateStudio, TOOLS.deepLink, TOOLS.artwork, TOOLS.knowledge],
allowedTools: [
TOOLS.studio,
TOOLS.validateStudio,
TOOLS.deepLink,
TOOLS.artwork,
TOOLS.knowledge,
],
confidence: 'high',
};
}
Expand Down
13 changes: 2 additions & 11 deletions apps/storefront/lib/concierge/knowledge/corpus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,7 @@ import { createHash } from 'node:crypto';
import type { ConciergeCitation } from '@tms/contracts';

export type KnowledgeSourceType =
| 'product'
| 'artwork'
| 'collection'
| 'policy'
| 'story'
| 'faq'
| 'page'
| 'article';
'product' | 'artwork' | 'collection' | 'policy' | 'story' | 'faq' | 'page' | 'article';

export interface KnowledgeRecord {
sourceType: KnowledgeSourceType;
Expand Down Expand Up @@ -155,9 +148,7 @@ export const KNOWLEDGE_CORPUS: readonly KnowledgeRecord[] = [

function tokenize(text: string): string[] {
const normalized = text.toLowerCase().replace(/f\.?\s*a\.?\s*t\.?\s*u/g, 'fatu');
return normalized
.split(/[^a-z0-9₦]+/i)
.filter((t) => t.length > 2);
return normalized.split(/[^a-z0-9₦]+/i).filter((t) => t.length > 2);
}

/**
Expand Down
Loading