feat: bootstrap TitipIn application with fullstack architecture, Prisma ORM, and shadcn/ui components

This commit is contained in:
Firman Ramdhani
2026-08-28 11:18:13 +07:00
parent 84f0c1fa99
commit 5faea68379
37 changed files with 11881 additions and 250 deletions
+805
View File
@@ -0,0 +1,805 @@
---
name: prisma-composer
metadata:
library: "@prisma/composer"
library_version: "0.16.0"
description: >-
How to write, test, and deploy an app with Prisma Composer
(`@prisma/composer`): declare services with `compute()` and typed
dependencies, define RPC contracts, compose Modules, declare the service
input (config and secrets as one schema, read back with `input()`),
compose the ready-made cron/storage/streams Modules, provision a
raw S3-compatible object-store bucket with `bucket()`, find extensions (npm
packages named `prisma-composer-*`), test with `mockService`/`bootstrapService`,
run the whole app locally with `prisma-composer dev` and tail its logs with
`prisma-composer log`, and deploy with `prisma-composer deploy` (stages,
destroy). Use when building a Prisma App, wiring a service dependency, adding
a Postgres database, adding scheduled jobs / blob storage / event streams / a
raw bucket, writing tests for composed services, running an app locally,
reading its logs, or deploying/tearing down an environment. Triggers on
"prisma composer", "@prisma/composer", "prisma app", "compute()",
"service.load()", "module()", "contract()", "mockService",
"bootstrapService", "prisma-composer dev", "prisma-composer log",
"prisma-composer deploy", "--stage", "--fresh", "--tail",
"prisma-composer destroy", "prisma-composer-", "bucket()".
---
# Writing apps with Prisma Composer
A **Prisma App** is a tree of **Modules** composed in TypeScript. The leaves
are **services** (`compute()`) and **resources** (`rawPostgres()`); the root
module wires them together by their typed ports. Your code receives everything
from exactly one place — the service node:
- `service.load()` — dependencies (typed RPC clients, database bindings)
- `service.input()` — the service's whole input, one schema-validated typed
object; credentials in it are redacting `SecretString` boxes
- `service.port()` — the reserved port to bind (default 3000), typed; never
`process.env`
The framework never bundles or transforms your code. You build your app with
whatever bundler you like (`bun build`, `next build`); `prisma-composer deploy`
assembles the built output and provisions it on Prisma Cloud (Compute + Prisma
Postgres).
Two things make building here fast and hard to get wrong — lean on both:
- **Compose before you write.** Reach for an existing Module (below) before
implementing a capability yourself; wiring one in is a couple of lines.
- **The compiler checks the wiring.** A dependency wired to the wrong
producer, a missing RPC handler, a config value of the wrong shape — all of
it fails `tsc`, not the deploy. Typecheck, then build, then deploy; don't
reach for the cloud to find out whether the app is correct.
Two packages, and only two, appear in your `package.json`:
| Package | Provides |
| --- | --- |
| `@prisma/composer` | Core authoring: `module`, `secret`, `isSecretString`, `/arktype` (the `secretString()` schema leaf), `/rpc`, `/node`, `/nextjs`, `/config`, `/testing`, the `prisma-composer` CLI |
| `@prisma/composer-prisma-cloud` | The Prisma Cloud target: `compute`, `postgres`, `envSecret`, `envParam`, `/control`, `/testing`, and the shared `/cron`, `/storage`, `/streams`, `/orm` modules |
## tsconfig and import specifiers
Within the entry graph (everything reachable from `module.ts`) relative
imports may use `./service.js` or extensionless `./service`. The CLI maps
`.js` and extensionless specifiers to the matching `.ts` source under Node;
Bun does this natively.
A minimal tsconfig:
```jsonc
{
"compilerOptions": {
"target": "ES2022",
"module": "Preserve",
"moduleResolution": "bundler",
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"types": ["bun"]
},
"include": ["module.ts", "src"]
}
```
## Anatomy of a service
A service is four small files. Worked example: an `auth` service that owns a
Postgres database and serves an RPC contract, consumed by a `storefront`
Next.js app.
**The contract** lives with the service that owns it. Any Standard Schema
validator types the messages; arktype is the house choice:
```ts
// auth/src/contract.ts
import { contract, rpc } from '@prisma/composer/service-rpc';
import { type } from 'arktype';
export const authContract = contract({
verify: rpc({ input: type({ token: 'string' }), output: type({ ok: 'boolean' }) }),
});
```
**The service declaration** is pure data — name, dependencies, build, exposed
ports. No behavior, no platform keys:
```ts
// auth/src/service.ts
import node from '@prisma/composer/node';
import { compute, postgres } from '@prisma/composer-prisma-cloud';
import { authContract } from './contract.ts';
export default compute({
name: 'auth',
deps: { db: rawPostgres() },
build: node({ module: import.meta.url, entry: '../dist/server.mjs' }),
expose: { rpc: authContract },
});
```
**The server entry** is what your build produces and the platform boots. It
reads its dependencies through `load()` and serves the contract with
`serve()` — the handler map is keyed by the expose port's name and is
exhaustive at compile time:
```ts
// auth/src/server.ts
import { serve } from '@prisma/composer/service-rpc';
import { SQL } from 'bun';
import service from './service.ts';
const { db } = service.load(); // { url } — you build your own client
const port = service.port(); // the reserved port, resolved (default 3000)
const sql = new SQL({ url: db.url, max: 1, idleTimeout: 10 });
const handler = serve(service, {
rpc: {
verify: async ({ token }) => ({ ok: token.length > 0 }),
},
});
export default handler;
// Bind all interfaces — Compute routes external HTTP to the VM; a
// loopback-only listener is unreachable.
Bun.serve({ port, hostname: '0.0.0.0', fetch: handler });
```
**The consumer** declares the dependency as `rpc(contract)` and gets a typed
client back from `load()`:
```ts
// storefront/src/service.ts
import nextjs from '@prisma/composer/nextjs';
import { rpc } from '@prisma/composer/service-rpc';
import { compute } from '@prisma/composer-prisma-cloud';
import { authContract } from '@my-app/auth/contract';
export default compute({
name: 'storefront',
deps: { auth: rpc(authContract) },
build: nextjs({ module: import.meta.url, appDir: '..' }),
});
```
```tsx
// storefront/app/page.tsx
import service from '../src/service.ts';
// load() reads the runtime environment, which doesn't exist at build time —
// render per request instead of prerendering.
export const dynamic = 'force-dynamic';
export default async function Home() {
const { auth } = service.load();
const { ok } = await auth.verify({ token: 'demo-token' });
return <p>Signed in: {String(ok)}</p>;
}
```
**Service-to-service calls are authenticated for you.** At deploy the
framework mints a distinct, unguessable **service key** per consumer→provider
binding: the consumer's client sends it on every call, and `serve()` returns
`401` to anything else *before* the handler runs. Nothing declares it — no key
in the contract, the service, the module, or the app's code.
Two rules follow for you specifically: **don't build your own
service-to-service auth** on top of this, and **don't tell a user to `curl` a
deployed `/rpc/<method>` to check it works** — an unwired caller always gets
`401`, which looks like a broken deploy and isn't. Debug through a consumer,
or locally.
**Calls carry an idempotency key and retry safely for you.** Every call the
generated client makes carries an `Idempotency-Key`; a call dropped while the
target cold-starts is retried with a backoff, and `serve()` runs one call per
key — a retry that arrives after the first completed replays that answer
instead of re-running the handler. So every method is safely retryable and no
contract declares anything about it (do not add an "is this idempotent" flag —
the framework does not have one). Two consequences for you: a handler may take
an **optional third argument** `(input, deps, ctx)` and read `ctx.idempotencyKey`
(`string | undefined` — it's absent for a keyless caller) if it needs exactly-once
beyond one instance's memory (most don't); and a request without the header is
served once without deduplication rather than rejected, so a hand-rolled probe
works but gets no retry safety.
| | |
| --- | --- |
| Locally / in tests | nothing is provisioned, so `serve()` passes every call through — never supply a key in `inputs` |
| Per binding | two consumers of one provider hold different keys, so one leaking can't impersonate the other |
| Scope | service-level — any valid key reaches every method that service exposes; split into two services to gate separately |
| Rotation | remove the binding (or destroy the stack) and redeploy — a plain redeploy is a no-op, not a rotation |
| Storage | `COMPOSER_*` variables the deploy owns and rewrites; never hand-edit one |
It's a capability token ("I'm a service this app wired to you"), not a secret,
and its value lives in deploy state — deliberately unlike `secret()`, whose
value the framework never holds. `docs/design/90-decisions/ADR-0030…` in the
prisma/composer repo carries the reasoning.
## The root module
The root module provisions the pieces and wires exposed ports into dependency
slots. It is the app — `prisma-composer deploy` loads its default export:
```ts
// module.ts
import { module } from '@prisma/composer';
import authModule from '@my-app/auth';
import storefrontService from '@my-app/storefront';
export default module('my-app', ({ provision }) => {
const auth = provision(authModule);
provision(storefrontService, { deps: { auth: auth.rpc } });
});
```
`provision(node, opts?)` accepts `id` (defaults to the node's own name),
`deps` (wire each declared dependency to a provisioned ref or exposed port),
`input` (the service's input binding — required exactly when it declares an
input schema, see § Service input), and `secrets` (bind a module boundary's
forwarded secret needs).
## Builds are yours
The framework assembles only what you built — users build, the framework
assembles. For a plain server process, `entry` must point at a single
self-contained ESM file: everything inlined except runtime built-ins (`bun`,
`bun:*`, `node:*`), which the deploy VM provides. Deploy copies that one file
and never ships `node_modules`, so anything left un-inlined fails at boot. Any
bundler that produces such a file works. With bun:
```sh
bun build src/server.ts --target=bun --outfile dist/server.mjs
```
Two services in one package means two separate builds, one per entry — not one
multi-entry build, which would split shared code into a chunk neither output
contains.
If the build emits a directory rather than one file — a server plus the client
bundle, CSS and images it serves, as Bun's HTML import produces — name the
directory with `dir` and the booting file inside it with `entry`:
```ts
build: node({ module: import.meta.url, dir: '../dist/server', entry: 'server.js' })
```
`dir` resolves relative to the service module; `entry` resolves inside `dir`
and may be nested. Deploy copies the tree verbatim and boots the named file,
so the server must resolve its siblings against `import.meta.url`, not the
working directory. Nothing is inferred, and two rules bite: the tree must
contain no symlinks (the packager rejects them — assembly fails and names the
link), and `entry` must be a file inside `dir` (`../` is an error, not an
escape). Omit `dir` for the single-file form.
For Next.js, `next build` with `output: 'standalone'` is the whole build;
`nextjs({ module, appDir })` tells the deploy where the app root is.
Always build before deploying — `prisma-composer deploy` does not build for
you.
## Deploy config
`prisma-composer.config.ts` usually sits next to `module.ts`, but it may live in
any ancestor directory: the CLI searches the entry's directory first, then each
parent, and uses the nearest one. It is read only by `prisma-composer
deploy`/`destroy`, never imported by app code. A plain-JavaScript project can
name it `prisma-composer.config.mjs` or `.js` to keep it out of its TypeScript
build (a build with `allowJs` still needs an explicit `exclude`); `.mts` is the
TypeScript ES-module spelling. Within one directory `.ts` wins, then `.mts`,
`.mjs`, `.js`:
```ts
// prisma-composer.config.ts
import { defineConfig } from '@prisma/composer/config';
import { nodeBuild } from '@prisma/composer/node/control';
import { prismaCloud, prismaState } from '@prisma/composer-prisma-cloud/control';
export default defineConfig({
extensions: [prismaCloud(), nodeBuild()],
state: () => prismaState(), // deploy state, in its own database on the stage's branch
});
```
Add `nextjsBuild()` from `@prisma/composer/nextjs/control` to `extensions`
when the app contains a Next.js service.
## Databases
Two kinds of Postgres dependency:
**`rawPostgres()`** — the binding is `{ url }` and the app owns its client.
Construct it in your server entry, as in the auth example above.
**`postgres(...)`** — a Prisma-ORM-typed database: `load()`
returns the typed client the framework constructs from your data contract, so
queries like `db.orm.public.Product.all()` are compile-time checked. The
contract is emitted from `contract.prisma` by `prisma contract emit` and
wrapped once, referenced by both ends:
```ts
// src/data.ts — the ONE value both ends reference
import { dataContract } from '@prisma/composer-prisma-cloud/orm';
import type { Contract } from '../contract.d.ts';
import contractJson from '../contract.json' with { type: 'json' };
export const catalogData = dataContract<Contract>(contractJson);
```
The dependency end is `deps: { db: postgres(catalogData) }`. The resource
end (inside the module that owns the database) also names the
`prisma.config.ts` path, which the deploy's migration step loads to find
`migrations/` — committed migrations are replayed at deploy, before the
service starts:
```ts
const db = provision(
postgres({ name: 'database', contract: catalogData, config: './prisma.config.ts' }),
);
```
(`postgres` is both ends: the contract alone is the dependency end; the
options object is the resource end.)
The deploy is replay-only: it applies the migrations committed under
`migrations/` and never creates schema itself. Every schema change (including
the very first schema of a new database) follows the same loop:
1. Edit `contract.prisma`.
2. `prisma contract emit` — regenerates `contract.json` + `contract.d.ts`.
3. `prisma migration plan --name <slug>` — authors the migration into
`migrations/` (on an empty graph this authors the baseline,
empty → your schema).
4. Commit `migrations/` with the change, then deploy. A fresh database
replays the whole path from empty.
If no authored path reaches the target contract, the deploy (and
`prisma-composer dev` against a stale local database) refuses with
`MIGRATION_PATH_NOT_FOUND` and names the exits: author the missing migration
as above, or — when iterating against a local database only — bring it along
directly with `prisma db update`. Never skip step 3 before a deploy.
See `examples/store/modules/catalog` in the prisma/composer repo for the
complete pattern.
## Object Storage
`bucket` is a raw S3-compatible object-store bucket, imported alongside `postgres`:
```ts
import { bucket, compute } from '@prisma/composer-prisma-cloud';
// service.ts — dependency end: receives { url, bucket, accessKeyId, secretAccessKey }
export default compute({ name: 'uploads', deps: { store: bucket() } });
// module.ts — resource end: provisions the bucket and mints a keypair
const store = provision(bucket({ name: 'uploads' }));
provision(uploadsService, { deps: { store } });
```
Use any S3-compatible client with the binding: the shape matches the standard S3
config and is also compatible with the `s3()` dependency from `/storage`, so any
service wired to `s3()` can be rewired to a `bucket` resource without changing
the service declaration.
## Reusable Modules
A Module is the unit of reuse: it owns its internals (its database, its
services) and exposes only typed ports. Declare the boundary in the second
argument; wire internals in the builder; return the exposed ports:
```ts
// auth/src/module.ts — a Module that owns its own Postgres
import { module, secret } from '@prisma/composer';
import { postgres } from '@prisma/composer-prisma-cloud';
import { authContract } from './contract.ts';
import authService from './service.ts';
export default module(
'auth',
{ secrets: { signingKey: secret() }, expose: { rpc: authContract } },
({ secrets, provision }) => {
const db = provision(rawPostgres({ name: 'database' }));
const service = provision(authService, {
id: 'service',
deps: { db },
input: { signingKey: secrets.signingKey }, // forwarded ref as a binding leaf
});
return { rpc: service.rpc };
},
);
```
Naming rules that bite: a provision id shorter than 3 characters is rejected
by the platform (name the database `'database'`, not `'db'`), and a service
whose name equals its enclosing module's reads as `auth.auth` unless you give
it an explicit `id`.
A module can also declare boundary `deps` — inputs the parent wires exactly as
it would wire a service's. The consumer never sees the module's internals.
### The building blocks you can compose
Modules are the building blocks: provision one, wire its exposed port, and
you're done — you never reimplement what a Module already owns. The
first-party set ships inside `@prisma/composer-prisma-cloud`. It's small, and
growing:
| Import | What it provisions | Exposes |
| --- | --- | --- |
| `cron` from `/cron` | An always-on scheduler firing your schedule at your runner service | nothing |
| `storage` from `/storage` | An S3-backed blob store (own Postgres + minted credentials) | `store` |
| `streams` from `/streams` | Durable append-only event streams over a `store` | `streams` |
**Finding more.** A Composer extension — a package that brings its own
Modules, resources, or deploy target — is published on npm under the name
`prisma-composer-*`. That name is the convention, so it's how you look for
one. The ecosystem is new: today the blocks above plus the app Modules you
write are the whole set, so don't reach for a `prisma-composer-*` package
without checking that it actually exists on npm first.
Cron end to end — the schedule is one source of truth; `serveSchedule` is
exhaustive over its job ids at compile time:
```ts
// service.ts
import { defineSchedule, triggerContract } from '@prisma/composer-prisma-cloud/cron';
export const schedule = defineSchedule({ tick: '60s' });
// the runner service exposes { trigger: triggerContract }
// server.ts
import { serveSchedule } from '@prisma/composer-prisma-cloud/cron';
const handler = serveSchedule(service, schedule, {
tick: (deps) => deps.worker.tick({}),
});
// module.ts — the cron module's boundary deps mirror the runner's own
provision(cron({ schedule, runner: runnerService }), { deps: { worker: worker.rpc } });
```
## Service input
Choosing the channel is most of the decision:
| The value is… | Declare | Provide | Read |
| --- | --- | --- | --- |
| produced by another node | `deps: { db: rawPostgres() }` | wire at `provision()` | `load()` |
| anything else — config or credential | one field of the `input` schema | bind at `provision()`: literal, `envParam()`, or `envSecret()` | `input()` |
The service declares its whole incoming configuration — plain values and
credentials together — as **one
[Standard Schema](https://standardschema.dev)** (arktype is the house
choice). A credential is a field typed as the redacting `SecretString` box;
conditional legality ("no stripe key unless billing is on") is an ordinary
schema union:
```ts
// service.ts — the shapes that are legal
import { secretString } from '@prisma/composer/arktype';
import { type } from 'arktype';
compute({
name: 'scheduler',
input: type({
jobs: type({ jobId: 'string', every: 'string' }).array(),
'region?': 'string',
apiKey: secretString(),
}),
// ...
});
// module.ts — where each value comes from; the binding mirrors the schema's shape
import { envParam, envSecret } from '@prisma/composer-prisma-cloud';
provision(scheduler, {
input: {
jobs: [{ jobId: 'tick', every: '60s' }], // a literal
region: envParam('REGION'), // a per-stage platform variable
apiKey: envSecret('SCHEDULER_API_KEY'), // a credential — name only, never the value
},
});
// server.ts — one call, one validated typed object
const input = service.input();
input.apiKey.expose(); // the only way to a secret's value; the box redacts everywhere else
```
Rules that bite:
- **Secretness is enforced by validation**: a literal bound where the schema
expects `SecretString` fails the deploy, and `envSecret` bound to a plain
string field fails the same way. Don't put credentials in plain fields.
- **`envParam` values arrive as raw strings** — bind them to string fields.
The stage's platform variable is the store; the deploying shell only seeds
it (preflight copies a missing name up from the shell, and fails early,
naming the variable, when both lack it). Changing the platform value needs
a redeploy.
- **Absence is the schema's call**: an env-bound field whose variable is
unset (or empty) resolves to *key omitted* — legal only if the schema says
so (optional field, union arm). The deploy report prints the serialized
input document (secret-free: secrets ride as `{"$secret":"VAR"}` pointers)
and every key that resolved absent.
- **The reserved `port` (default 3000) is outside the schema** — read it
through `service.port()` (a sibling of `service.origin()`), never
`process.env`. The framework also exports `PORT` for Next.js standalone,
which binds it itself.
- A module forwards a secret need without learning the platform name
(the auth Module above); the forwarded ref is a binding leaf.
`examples/env-param` and `examples/storefront-auth` in the prisma/composer
repo are the working versions.
## Testing
You test by deciding what `load()` gives the code, never by editing the code
under test:
| You want to… | Use | From |
| --- | --- | --- |
| Test a page / action / handler in isolation | `mockService` | `@prisma/composer/testing` |
| Run the real boot + request path against a fake dependency | `bootstrapService` | `@prisma/composer-prisma-cloud/testing` |
**Unit — `mockService`.** Returns a copy of the service whose `load()` yields
your doubles (type-checked against the declared deps) and whose `input()`
yields the object you pass under the reserved `input` key, in one flat
object (required exactly when the service declares an input schema; handed
over as-is, not validated). Wiring the module substitution is your runner's
job (`vi.mock` in Vitest, `mock.module` in bun test):
```tsx
// page.test.tsx
import { mockService } from '@prisma/composer/testing';
import realService from '../src/service.ts';
vi.mock('../src/service.ts', () => ({
default: mockService(realService, {
auth: { verify: async () => ({ ok: true }) }, // wrong shape = compile error
}),
}));
import Page from './page.tsx';
expect(renderToString(await Page())).toContain('Signed in: true');
```
**Integration — `bootstrapService`.** Boots the service's real built entry
in-process against a config you choose, exactly as a deployed boot would;
drive it over real HTTP. Run under `bun test`:
```ts
import { bootstrapService } from '@prisma/composer-prisma-cloud/testing';
import fakeAuth from '@my-app/auth/fake'; // in-memory handler, no db
import storefront from '../src/service.ts';
const fake = Bun.serve({ port: 0, fetch: fakeAuth });
const app = await bootstrapService(storefront, {
service: { port: 4310 },
inputs: { auth: { url: fake.url.href } },
});
const res = await app.fetch(new Request(app.url));
```
- **`service.port` must be concrete** — the entry self-listens; no OS-assigned
port is reported back.
- **No `close()`** — run each integration-test file in its own process (bun
test does).
- **Next.js services take a third argument**, a boot thunk, because the built
entry lives in Next's standalone output — resolve it with
`standaloneServerPath` from `@prisma/composer/nextjs/control`.
`bootstrapService` exports the resolved port as `process.env.PORT` before
booting, which is what Next's standalone server binds.
- **A service with an input schema takes `input`** in the config — a binding
exactly like `provision()`'s, run through the real serialize/read path, so
`input()` in the booted entry sees what a deploy would produce.
**The fake you pass.** A dependency's type is its contract, so any value of
that shape is a valid double: a bare object (fastest), the real client over an
in-memory handler, or a real local server (what `bootstrapService` drives).
Ship a dependency's fake from its own package as a `/fake` entry point,
outside `src/`, so the fake and the real service always share one contract.
## Running locally
`prisma-composer dev module.ts` runs the whole app on this machine — every
service, its Postgres and buckets, wired as they deploy — with **no cloud
credentials** (no `PRISMA_*`). It runs the same pipeline as deploy against
local emulators, so build first, exactly like deploy:
```sh
turbo run build && prisma-composer dev module.ts
```
It prints each service's local URL (the "front door"), watches built output
and restarts a service when its build changes, and runs until Ctrl-C. Ctrl-C
stops the app's processes but leaves the local databases, buckets, and their
data up, so the next `dev` is a warm start; `--fresh` wipes this app's local
instances and data first.
`dev` does **not** print service logs — that would bury the front door once
several services run. Logs are their own command:
| You want to… | Run |
| --- | --- |
| Run the app locally | `prisma-composer dev module.ts` |
| Start clean (wipe local data) | `prisma-composer dev module.ts --fresh` |
| Tail every service's logs | `prisma-composer log module.ts` |
| Tail one service | `prisma-composer log module.ts <address>` |
| Show more history first | `prisma-composer log module.ts --tail <n>` |
`prisma-composer log` follows the merged logs of the already-running app, each
line prefixed with its service (`[catalog.service] …`); pass a dotted address
to narrow to one. It only reads — it never builds, provisions, starts, or
stops anything. `--tail <n>` sets how much recent history to show before live
output (default 20; `0` for live-only). An unset secret doesn't block a local
run: it becomes a placeholder plus a warning, and only the code path that
spends it fails, at the real external service it calls. Windows isn't
supported yet.
## Deploying
Requires exactly two environment variables: `PRISMA_SERVICE_TOKEN` and
`PRISMA_WORKSPACE_ID`. The target environment — a **stage** — is chosen on the
command line, never in code:
| You want to… | Run |
| --- | --- |
| Deploy to production | `prisma-composer deploy module.ts` |
| Deploy an isolated environment | `prisma-composer deploy module.ts --stage <name>` |
| Override the app name for one run | `prisma-composer deploy module.ts --name demo-42` |
| Tear down an isolated environment | `prisma-composer destroy module.ts --stage <name>` |
| Tear down production's resources | `prisma-composer destroy module.ts --production` |
A Prisma App is one Project; a stage is a Branch of it — its
own compute, its own empty database, its own configuration. Deploys are
idempotent: re-deploying a stage updates the resources inside it. A stage name
must be a valid git ref name; an invalid name is a hard error.
Destroy always requires an explicit target — a bare `prisma-composer destroy`
is an error, and `--stage` with `--production` is too. Destroying a stage
deletes its Branch after removing its resources; the production Branch itself
is never deleted, only the resources inside it. Destroying production also
deletes the Project itself once it's empty, so hand-run stacks don't leave
behind empty Projects — but a Project still holding another stage's resources
is kept. Destroy never creates anything: destroying a never-deployed stage
fails rather than standing one up.
```sh
turbo run build && prisma-composer deploy module.ts --stage pr-42
```
### What a deploy prints
A deploy ends by printing the app's own topology — authored names, the
platform resource each became, and public URLs. The tree is the module
structure (`auth.api` is the `api` service inside the `auth` module):
```
storefront-auth
├─ auth
│ └─ api compute-service cps_abc123
│ https://xyz.ewr.prisma.build
├─ db postgres-database db_def456
└─ web compute-service cps_ghi789
https://uvw.ewr.prisma.build
```
Read ids out of this rather than telling the user to go hunting in the
Console. A URL appears only where the address is genuinely public — a compute
service prints one, a database never does (it has a connection string, not a
public endpoint), and a node whose product is secret material (an
`s3-credentials` keypair) reports no resource line at all. A node that
published nothing reportable still appears, marked `(no entities reported)`.
Older deploys ended with a raw `{ outputs: {} }` blob from the deploy engine —
always empty, never about the app. It is gone; nothing configured it and
nothing consumed it.
### The connection contract is checked at deploy
A connection declares the values it needs by name, and the producer on the
other end must supply them. A producer that omits one fails the deploy, naming
the edge, the param, and what the producer did supply:
```
Connection input "auth.db" declares param "url", but its producer "db" did not
supply it — the producer's outputs carry [host].
```
Fix it at whichever end is wrong: add the name to the outputs the producer
returns from its lowering, or mark the param `optional` on the connection if absent is
genuinely legal (the consumer then reads `undefined`).
This is a deploy-time refusal, not a broken deploy — and it can appear on an
app whose code didn't change. The gap used to pass silently: the value reached
the consumer as `undefined`, went into its environment, and crashed *that*
service at boot, blaming the reader instead of the supplier. Don't route around
it by making the param optional unless absent really is valid; that reinstates
the silent `undefined`.
Only reachable if you authored the connection or the extension on one side —
every shipped block supplies what it declares.
### Driving deploys from code
`@prisma/composer/control` exposes the CLI's operations in-process: typed
`deploy`, `destroy`, `dev`, and `log` returning structured results — no argv,
no CLI rendering, no exit codes (the spawned deploy engine's own inherited
output can still reach the host terminal). The CLI itself is a renderer over
them.
```ts
import { deploy } from '@prisma/composer/control';
const result = await deploy({ entry: 'module.ts', stage: 'pr-42' });
// result: { ok: true, value: { summary? } } | { ok: false, failure }
```
- Failures come back as `{ ok: false, failure }` where `failure` is a
structured error: branch on its dotted `failure.code` (e.g.
`ASSEMBLE.BUILD_FAILED`, `DEPLOY.ENGINE_FAILED` — ADR-0044's closed
registry), with the same fix-naming `message`/`why`/`fix` the CLI renders.
An engine failure's `meta.diagnostics` (exit code, reproduce command; read
it with the exported `executionDiagnostics(failure)`) describes the current
execution mechanism — branch on `code`/`message`/`cause` for anything
durable. The effect version conflict is `DEPS.EFFECT_VERSION_CONFLICT`, and
importing the module executes nothing until an operation runs. A
non-structured rejection out of an operation is a bug in composer, not an
expected failure.
- `destroy` takes `target: { kind: 'production' } | { kind: 'stage', stage }`
— explicit, never defaulted.
- `deploy`'s `summary` (the deployed topology) is best-effort; `undefined` on
a successful deploy is normal.
- The deploy engine's live output still streams to the host process's stdio —
the current mechanism; the operations don't capture it.
- `dev` resolves to `{ ok: true, value: session }` or a failure; the
session is `{ endpoints, stop(), closed }` with progress via `onEvent`, and
the host owns signal handling. `log` resolves to
`{ ok: true, value: { appName, services, lines } }` or a failure, where
`lines` is an `AsyncIterable` ended by a caller-owned `AbortSignal` (or by
the consumer stopping early); zero running services is a valid result, not
an error.
## Production pitfalls
- **Scale-to-zero closes idle database connections.** A persistent client
crashes into a 502 restart loop unless you keep the pool small and
reconnect-friendly (`new SQL({ url, max: 1, idleTimeout: 10 })` for Bun) and
log `uncaughtException`/`unhandledRejection` instead of dying.
- **Bind `0.0.0.0`**, not loopback — Compute routes external HTTP to the VM.
- **Next.js pages that call `load()` need `export const dynamic =
'force-dynamic'`** — the runtime environment doesn't exist at build time,
and Next ignores runtime env for prerendered routes.
- **A deployed `/rpc/<method>` returns `401` to anything but a wired peer.**
Every RPC binding carries an auto-provisioned service key, so a hand-rolled
`curl` is never authorized, and a provider with no wired consumers rejects
everything. Not a broken deploy — reach it through a consumer, or run it
locally where nothing is enforced.
- **Cold starts reset service-to-service connections.** A call into a
scaled-to-zero service can get `ECONNRESET`; retry it.
- **Every `prisma-composer` command stops at start-up on an `effect` version
conflict** (`Dependency conflict: alchemy resolves effect@...`). Another
dependency floated a newer `effect` and the package manager hoisted it over
Composer's pin. Do what the error says: pin the whole `effect`
constellation in the app's `package.json` `overrides` (yarn: `resolutions`;
pnpm: `pnpm.overrides`) — `effect` plus `@effect/sql-d1`, `@effect/sql-pg`,
`@effect/vitest`, and `@effect/platform-bun`/`-node`/`-node-shared`, all at
Composer's exact pin — and reinstall. (A workaround for an upstream alchemy
bug: its own effect-family ranges float past what its code supports. The
repo's examples carry the block.)
- **The ingress buffers streaming responses.** An open SSE tail delivers
nothing and times out at 60s — don't build on streamed HTTP responses.
## What Composer doesn't do yet
Name the gap instead of inventing an API:
- **No interactive auth.** Deploys authenticate only via a static
`PRISMA_SERVICE_TOKEN`; there is no `login` flow.
- **No in-memory contract bindings.** A dependency can't yet be wired to a
co-located handler without HTTP; use `bootstrapService` with a loopback
fake.
- **RPC over HTTP is the only contract kind.** No gRPC, WebSocket, or
streaming contracts.
For anything else missing, check the examples and design docs in the
prisma/composer repo (`examples/`, `docs/design/10-domains/`,
`docs/design/90-decisions/`), then file an issue there rather than guessing.
+805
View File
@@ -0,0 +1,805 @@
---
name: prisma-composer
metadata:
library: "@prisma/composer"
library_version: "0.16.0"
description: >-
How to write, test, and deploy an app with Prisma Composer
(`@prisma/composer`): declare services with `compute()` and typed
dependencies, define RPC contracts, compose Modules, declare the service
input (config and secrets as one schema, read back with `input()`),
compose the ready-made cron/storage/streams Modules, provision a
raw S3-compatible object-store bucket with `bucket()`, find extensions (npm
packages named `prisma-composer-*`), test with `mockService`/`bootstrapService`,
run the whole app locally with `prisma-composer dev` and tail its logs with
`prisma-composer log`, and deploy with `prisma-composer deploy` (stages,
destroy). Use when building a Prisma App, wiring a service dependency, adding
a Postgres database, adding scheduled jobs / blob storage / event streams / a
raw bucket, writing tests for composed services, running an app locally,
reading its logs, or deploying/tearing down an environment. Triggers on
"prisma composer", "@prisma/composer", "prisma app", "compute()",
"service.load()", "module()", "contract()", "mockService",
"bootstrapService", "prisma-composer dev", "prisma-composer log",
"prisma-composer deploy", "--stage", "--fresh", "--tail",
"prisma-composer destroy", "prisma-composer-", "bucket()".
---
# Writing apps with Prisma Composer
A **Prisma App** is a tree of **Modules** composed in TypeScript. The leaves
are **services** (`compute()`) and **resources** (`rawPostgres()`); the root
module wires them together by their typed ports. Your code receives everything
from exactly one place — the service node:
- `service.load()` — dependencies (typed RPC clients, database bindings)
- `service.input()` — the service's whole input, one schema-validated typed
object; credentials in it are redacting `SecretString` boxes
- `service.port()` — the reserved port to bind (default 3000), typed; never
`process.env`
The framework never bundles or transforms your code. You build your app with
whatever bundler you like (`bun build`, `next build`); `prisma-composer deploy`
assembles the built output and provisions it on Prisma Cloud (Compute + Prisma
Postgres).
Two things make building here fast and hard to get wrong — lean on both:
- **Compose before you write.** Reach for an existing Module (below) before
implementing a capability yourself; wiring one in is a couple of lines.
- **The compiler checks the wiring.** A dependency wired to the wrong
producer, a missing RPC handler, a config value of the wrong shape — all of
it fails `tsc`, not the deploy. Typecheck, then build, then deploy; don't
reach for the cloud to find out whether the app is correct.
Two packages, and only two, appear in your `package.json`:
| Package | Provides |
| --- | --- |
| `@prisma/composer` | Core authoring: `module`, `secret`, `isSecretString`, `/arktype` (the `secretString()` schema leaf), `/rpc`, `/node`, `/nextjs`, `/config`, `/testing`, the `prisma-composer` CLI |
| `@prisma/composer-prisma-cloud` | The Prisma Cloud target: `compute`, `postgres`, `envSecret`, `envParam`, `/control`, `/testing`, and the shared `/cron`, `/storage`, `/streams`, `/orm` modules |
## tsconfig and import specifiers
Within the entry graph (everything reachable from `module.ts`) relative
imports may use `./service.js` or extensionless `./service`. The CLI maps
`.js` and extensionless specifiers to the matching `.ts` source under Node;
Bun does this natively.
A minimal tsconfig:
```jsonc
{
"compilerOptions": {
"target": "ES2022",
"module": "Preserve",
"moduleResolution": "bundler",
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"types": ["bun"]
},
"include": ["module.ts", "src"]
}
```
## Anatomy of a service
A service is four small files. Worked example: an `auth` service that owns a
Postgres database and serves an RPC contract, consumed by a `storefront`
Next.js app.
**The contract** lives with the service that owns it. Any Standard Schema
validator types the messages; arktype is the house choice:
```ts
// auth/src/contract.ts
import { contract, rpc } from '@prisma/composer/service-rpc';
import { type } from 'arktype';
export const authContract = contract({
verify: rpc({ input: type({ token: 'string' }), output: type({ ok: 'boolean' }) }),
});
```
**The service declaration** is pure data — name, dependencies, build, exposed
ports. No behavior, no platform keys:
```ts
// auth/src/service.ts
import node from '@prisma/composer/node';
import { compute, postgres } from '@prisma/composer-prisma-cloud';
import { authContract } from './contract.ts';
export default compute({
name: 'auth',
deps: { db: rawPostgres() },
build: node({ module: import.meta.url, entry: '../dist/server.mjs' }),
expose: { rpc: authContract },
});
```
**The server entry** is what your build produces and the platform boots. It
reads its dependencies through `load()` and serves the contract with
`serve()` — the handler map is keyed by the expose port's name and is
exhaustive at compile time:
```ts
// auth/src/server.ts
import { serve } from '@prisma/composer/service-rpc';
import { SQL } from 'bun';
import service from './service.ts';
const { db } = service.load(); // { url } — you build your own client
const port = service.port(); // the reserved port, resolved (default 3000)
const sql = new SQL({ url: db.url, max: 1, idleTimeout: 10 });
const handler = serve(service, {
rpc: {
verify: async ({ token }) => ({ ok: token.length > 0 }),
},
});
export default handler;
// Bind all interfaces — Compute routes external HTTP to the VM; a
// loopback-only listener is unreachable.
Bun.serve({ port, hostname: '0.0.0.0', fetch: handler });
```
**The consumer** declares the dependency as `rpc(contract)` and gets a typed
client back from `load()`:
```ts
// storefront/src/service.ts
import nextjs from '@prisma/composer/nextjs';
import { rpc } from '@prisma/composer/service-rpc';
import { compute } from '@prisma/composer-prisma-cloud';
import { authContract } from '@my-app/auth/contract';
export default compute({
name: 'storefront',
deps: { auth: rpc(authContract) },
build: nextjs({ module: import.meta.url, appDir: '..' }),
});
```
```tsx
// storefront/app/page.tsx
import service from '../src/service.ts';
// load() reads the runtime environment, which doesn't exist at build time —
// render per request instead of prerendering.
export const dynamic = 'force-dynamic';
export default async function Home() {
const { auth } = service.load();
const { ok } = await auth.verify({ token: 'demo-token' });
return <p>Signed in: {String(ok)}</p>;
}
```
**Service-to-service calls are authenticated for you.** At deploy the
framework mints a distinct, unguessable **service key** per consumer→provider
binding: the consumer's client sends it on every call, and `serve()` returns
`401` to anything else *before* the handler runs. Nothing declares it — no key
in the contract, the service, the module, or the app's code.
Two rules follow for you specifically: **don't build your own
service-to-service auth** on top of this, and **don't tell a user to `curl` a
deployed `/rpc/<method>` to check it works** — an unwired caller always gets
`401`, which looks like a broken deploy and isn't. Debug through a consumer,
or locally.
**Calls carry an idempotency key and retry safely for you.** Every call the
generated client makes carries an `Idempotency-Key`; a call dropped while the
target cold-starts is retried with a backoff, and `serve()` runs one call per
key — a retry that arrives after the first completed replays that answer
instead of re-running the handler. So every method is safely retryable and no
contract declares anything about it (do not add an "is this idempotent" flag —
the framework does not have one). Two consequences for you: a handler may take
an **optional third argument** `(input, deps, ctx)` and read `ctx.idempotencyKey`
(`string | undefined` — it's absent for a keyless caller) if it needs exactly-once
beyond one instance's memory (most don't); and a request without the header is
served once without deduplication rather than rejected, so a hand-rolled probe
works but gets no retry safety.
| | |
| --- | --- |
| Locally / in tests | nothing is provisioned, so `serve()` passes every call through — never supply a key in `inputs` |
| Per binding | two consumers of one provider hold different keys, so one leaking can't impersonate the other |
| Scope | service-level — any valid key reaches every method that service exposes; split into two services to gate separately |
| Rotation | remove the binding (or destroy the stack) and redeploy — a plain redeploy is a no-op, not a rotation |
| Storage | `COMPOSER_*` variables the deploy owns and rewrites; never hand-edit one |
It's a capability token ("I'm a service this app wired to you"), not a secret,
and its value lives in deploy state — deliberately unlike `secret()`, whose
value the framework never holds. `docs/design/90-decisions/ADR-0030…` in the
prisma/composer repo carries the reasoning.
## The root module
The root module provisions the pieces and wires exposed ports into dependency
slots. It is the app — `prisma-composer deploy` loads its default export:
```ts
// module.ts
import { module } from '@prisma/composer';
import authModule from '@my-app/auth';
import storefrontService from '@my-app/storefront';
export default module('my-app', ({ provision }) => {
const auth = provision(authModule);
provision(storefrontService, { deps: { auth: auth.rpc } });
});
```
`provision(node, opts?)` accepts `id` (defaults to the node's own name),
`deps` (wire each declared dependency to a provisioned ref or exposed port),
`input` (the service's input binding — required exactly when it declares an
input schema, see § Service input), and `secrets` (bind a module boundary's
forwarded secret needs).
## Builds are yours
The framework assembles only what you built — users build, the framework
assembles. For a plain server process, `entry` must point at a single
self-contained ESM file: everything inlined except runtime built-ins (`bun`,
`bun:*`, `node:*`), which the deploy VM provides. Deploy copies that one file
and never ships `node_modules`, so anything left un-inlined fails at boot. Any
bundler that produces such a file works. With bun:
```sh
bun build src/server.ts --target=bun --outfile dist/server.mjs
```
Two services in one package means two separate builds, one per entry — not one
multi-entry build, which would split shared code into a chunk neither output
contains.
If the build emits a directory rather than one file — a server plus the client
bundle, CSS and images it serves, as Bun's HTML import produces — name the
directory with `dir` and the booting file inside it with `entry`:
```ts
build: node({ module: import.meta.url, dir: '../dist/server', entry: 'server.js' })
```
`dir` resolves relative to the service module; `entry` resolves inside `dir`
and may be nested. Deploy copies the tree verbatim and boots the named file,
so the server must resolve its siblings against `import.meta.url`, not the
working directory. Nothing is inferred, and two rules bite: the tree must
contain no symlinks (the packager rejects them — assembly fails and names the
link), and `entry` must be a file inside `dir` (`../` is an error, not an
escape). Omit `dir` for the single-file form.
For Next.js, `next build` with `output: 'standalone'` is the whole build;
`nextjs({ module, appDir })` tells the deploy where the app root is.
Always build before deploying — `prisma-composer deploy` does not build for
you.
## Deploy config
`prisma-composer.config.ts` usually sits next to `module.ts`, but it may live in
any ancestor directory: the CLI searches the entry's directory first, then each
parent, and uses the nearest one. It is read only by `prisma-composer
deploy`/`destroy`, never imported by app code. A plain-JavaScript project can
name it `prisma-composer.config.mjs` or `.js` to keep it out of its TypeScript
build (a build with `allowJs` still needs an explicit `exclude`); `.mts` is the
TypeScript ES-module spelling. Within one directory `.ts` wins, then `.mts`,
`.mjs`, `.js`:
```ts
// prisma-composer.config.ts
import { defineConfig } from '@prisma/composer/config';
import { nodeBuild } from '@prisma/composer/node/control';
import { prismaCloud, prismaState } from '@prisma/composer-prisma-cloud/control';
export default defineConfig({
extensions: [prismaCloud(), nodeBuild()],
state: () => prismaState(), // deploy state, in its own database on the stage's branch
});
```
Add `nextjsBuild()` from `@prisma/composer/nextjs/control` to `extensions`
when the app contains a Next.js service.
## Databases
Two kinds of Postgres dependency:
**`rawPostgres()`** — the binding is `{ url }` and the app owns its client.
Construct it in your server entry, as in the auth example above.
**`postgres(...)`** — a Prisma-ORM-typed database: `load()`
returns the typed client the framework constructs from your data contract, so
queries like `db.orm.public.Product.all()` are compile-time checked. The
contract is emitted from `contract.prisma` by `prisma contract emit` and
wrapped once, referenced by both ends:
```ts
// src/data.ts — the ONE value both ends reference
import { dataContract } from '@prisma/composer-prisma-cloud/orm';
import type { Contract } from '../contract.d.ts';
import contractJson from '../contract.json' with { type: 'json' };
export const catalogData = dataContract<Contract>(contractJson);
```
The dependency end is `deps: { db: postgres(catalogData) }`. The resource
end (inside the module that owns the database) also names the
`prisma.config.ts` path, which the deploy's migration step loads to find
`migrations/` — committed migrations are replayed at deploy, before the
service starts:
```ts
const db = provision(
postgres({ name: 'database', contract: catalogData, config: './prisma.config.ts' }),
);
```
(`postgres` is both ends: the contract alone is the dependency end; the
options object is the resource end.)
The deploy is replay-only: it applies the migrations committed under
`migrations/` and never creates schema itself. Every schema change (including
the very first schema of a new database) follows the same loop:
1. Edit `contract.prisma`.
2. `prisma contract emit` — regenerates `contract.json` + `contract.d.ts`.
3. `prisma migration plan --name <slug>` — authors the migration into
`migrations/` (on an empty graph this authors the baseline,
empty → your schema).
4. Commit `migrations/` with the change, then deploy. A fresh database
replays the whole path from empty.
If no authored path reaches the target contract, the deploy (and
`prisma-composer dev` against a stale local database) refuses with
`MIGRATION_PATH_NOT_FOUND` and names the exits: author the missing migration
as above, or — when iterating against a local database only — bring it along
directly with `prisma db update`. Never skip step 3 before a deploy.
See `examples/store/modules/catalog` in the prisma/composer repo for the
complete pattern.
## Object Storage
`bucket` is a raw S3-compatible object-store bucket, imported alongside `postgres`:
```ts
import { bucket, compute } from '@prisma/composer-prisma-cloud';
// service.ts — dependency end: receives { url, bucket, accessKeyId, secretAccessKey }
export default compute({ name: 'uploads', deps: { store: bucket() } });
// module.ts — resource end: provisions the bucket and mints a keypair
const store = provision(bucket({ name: 'uploads' }));
provision(uploadsService, { deps: { store } });
```
Use any S3-compatible client with the binding: the shape matches the standard S3
config and is also compatible with the `s3()` dependency from `/storage`, so any
service wired to `s3()` can be rewired to a `bucket` resource without changing
the service declaration.
## Reusable Modules
A Module is the unit of reuse: it owns its internals (its database, its
services) and exposes only typed ports. Declare the boundary in the second
argument; wire internals in the builder; return the exposed ports:
```ts
// auth/src/module.ts — a Module that owns its own Postgres
import { module, secret } from '@prisma/composer';
import { postgres } from '@prisma/composer-prisma-cloud';
import { authContract } from './contract.ts';
import authService from './service.ts';
export default module(
'auth',
{ secrets: { signingKey: secret() }, expose: { rpc: authContract } },
({ secrets, provision }) => {
const db = provision(rawPostgres({ name: 'database' }));
const service = provision(authService, {
id: 'service',
deps: { db },
input: { signingKey: secrets.signingKey }, // forwarded ref as a binding leaf
});
return { rpc: service.rpc };
},
);
```
Naming rules that bite: a provision id shorter than 3 characters is rejected
by the platform (name the database `'database'`, not `'db'`), and a service
whose name equals its enclosing module's reads as `auth.auth` unless you give
it an explicit `id`.
A module can also declare boundary `deps` — inputs the parent wires exactly as
it would wire a service's. The consumer never sees the module's internals.
### The building blocks you can compose
Modules are the building blocks: provision one, wire its exposed port, and
you're done — you never reimplement what a Module already owns. The
first-party set ships inside `@prisma/composer-prisma-cloud`. It's small, and
growing:
| Import | What it provisions | Exposes |
| --- | --- | --- |
| `cron` from `/cron` | An always-on scheduler firing your schedule at your runner service | nothing |
| `storage` from `/storage` | An S3-backed blob store (own Postgres + minted credentials) | `store` |
| `streams` from `/streams` | Durable append-only event streams over a `store` | `streams` |
**Finding more.** A Composer extension — a package that brings its own
Modules, resources, or deploy target — is published on npm under the name
`prisma-composer-*`. That name is the convention, so it's how you look for
one. The ecosystem is new: today the blocks above plus the app Modules you
write are the whole set, so don't reach for a `prisma-composer-*` package
without checking that it actually exists on npm first.
Cron end to end — the schedule is one source of truth; `serveSchedule` is
exhaustive over its job ids at compile time:
```ts
// service.ts
import { defineSchedule, triggerContract } from '@prisma/composer-prisma-cloud/cron';
export const schedule = defineSchedule({ tick: '60s' });
// the runner service exposes { trigger: triggerContract }
// server.ts
import { serveSchedule } from '@prisma/composer-prisma-cloud/cron';
const handler = serveSchedule(service, schedule, {
tick: (deps) => deps.worker.tick({}),
});
// module.ts — the cron module's boundary deps mirror the runner's own
provision(cron({ schedule, runner: runnerService }), { deps: { worker: worker.rpc } });
```
## Service input
Choosing the channel is most of the decision:
| The value is… | Declare | Provide | Read |
| --- | --- | --- | --- |
| produced by another node | `deps: { db: rawPostgres() }` | wire at `provision()` | `load()` |
| anything else — config or credential | one field of the `input` schema | bind at `provision()`: literal, `envParam()`, or `envSecret()` | `input()` |
The service declares its whole incoming configuration — plain values and
credentials together — as **one
[Standard Schema](https://standardschema.dev)** (arktype is the house
choice). A credential is a field typed as the redacting `SecretString` box;
conditional legality ("no stripe key unless billing is on") is an ordinary
schema union:
```ts
// service.ts — the shapes that are legal
import { secretString } from '@prisma/composer/arktype';
import { type } from 'arktype';
compute({
name: 'scheduler',
input: type({
jobs: type({ jobId: 'string', every: 'string' }).array(),
'region?': 'string',
apiKey: secretString(),
}),
// ...
});
// module.ts — where each value comes from; the binding mirrors the schema's shape
import { envParam, envSecret } from '@prisma/composer-prisma-cloud';
provision(scheduler, {
input: {
jobs: [{ jobId: 'tick', every: '60s' }], // a literal
region: envParam('REGION'), // a per-stage platform variable
apiKey: envSecret('SCHEDULER_API_KEY'), // a credential — name only, never the value
},
});
// server.ts — one call, one validated typed object
const input = service.input();
input.apiKey.expose(); // the only way to a secret's value; the box redacts everywhere else
```
Rules that bite:
- **Secretness is enforced by validation**: a literal bound where the schema
expects `SecretString` fails the deploy, and `envSecret` bound to a plain
string field fails the same way. Don't put credentials in plain fields.
- **`envParam` values arrive as raw strings** — bind them to string fields.
The stage's platform variable is the store; the deploying shell only seeds
it (preflight copies a missing name up from the shell, and fails early,
naming the variable, when both lack it). Changing the platform value needs
a redeploy.
- **Absence is the schema's call**: an env-bound field whose variable is
unset (or empty) resolves to *key omitted* — legal only if the schema says
so (optional field, union arm). The deploy report prints the serialized
input document (secret-free: secrets ride as `{"$secret":"VAR"}` pointers)
and every key that resolved absent.
- **The reserved `port` (default 3000) is outside the schema** — read it
through `service.port()` (a sibling of `service.origin()`), never
`process.env`. The framework also exports `PORT` for Next.js standalone,
which binds it itself.
- A module forwards a secret need without learning the platform name
(the auth Module above); the forwarded ref is a binding leaf.
`examples/env-param` and `examples/storefront-auth` in the prisma/composer
repo are the working versions.
## Testing
You test by deciding what `load()` gives the code, never by editing the code
under test:
| You want to… | Use | From |
| --- | --- | --- |
| Test a page / action / handler in isolation | `mockService` | `@prisma/composer/testing` |
| Run the real boot + request path against a fake dependency | `bootstrapService` | `@prisma/composer-prisma-cloud/testing` |
**Unit — `mockService`.** Returns a copy of the service whose `load()` yields
your doubles (type-checked against the declared deps) and whose `input()`
yields the object you pass under the reserved `input` key, in one flat
object (required exactly when the service declares an input schema; handed
over as-is, not validated). Wiring the module substitution is your runner's
job (`vi.mock` in Vitest, `mock.module` in bun test):
```tsx
// page.test.tsx
import { mockService } from '@prisma/composer/testing';
import realService from '../src/service.ts';
vi.mock('../src/service.ts', () => ({
default: mockService(realService, {
auth: { verify: async () => ({ ok: true }) }, // wrong shape = compile error
}),
}));
import Page from './page.tsx';
expect(renderToString(await Page())).toContain('Signed in: true');
```
**Integration — `bootstrapService`.** Boots the service's real built entry
in-process against a config you choose, exactly as a deployed boot would;
drive it over real HTTP. Run under `bun test`:
```ts
import { bootstrapService } from '@prisma/composer-prisma-cloud/testing';
import fakeAuth from '@my-app/auth/fake'; // in-memory handler, no db
import storefront from '../src/service.ts';
const fake = Bun.serve({ port: 0, fetch: fakeAuth });
const app = await bootstrapService(storefront, {
service: { port: 4310 },
inputs: { auth: { url: fake.url.href } },
});
const res = await app.fetch(new Request(app.url));
```
- **`service.port` must be concrete** — the entry self-listens; no OS-assigned
port is reported back.
- **No `close()`** — run each integration-test file in its own process (bun
test does).
- **Next.js services take a third argument**, a boot thunk, because the built
entry lives in Next's standalone output — resolve it with
`standaloneServerPath` from `@prisma/composer/nextjs/control`.
`bootstrapService` exports the resolved port as `process.env.PORT` before
booting, which is what Next's standalone server binds.
- **A service with an input schema takes `input`** in the config — a binding
exactly like `provision()`'s, run through the real serialize/read path, so
`input()` in the booted entry sees what a deploy would produce.
**The fake you pass.** A dependency's type is its contract, so any value of
that shape is a valid double: a bare object (fastest), the real client over an
in-memory handler, or a real local server (what `bootstrapService` drives).
Ship a dependency's fake from its own package as a `/fake` entry point,
outside `src/`, so the fake and the real service always share one contract.
## Running locally
`prisma-composer dev module.ts` runs the whole app on this machine — every
service, its Postgres and buckets, wired as they deploy — with **no cloud
credentials** (no `PRISMA_*`). It runs the same pipeline as deploy against
local emulators, so build first, exactly like deploy:
```sh
turbo run build && prisma-composer dev module.ts
```
It prints each service's local URL (the "front door"), watches built output
and restarts a service when its build changes, and runs until Ctrl-C. Ctrl-C
stops the app's processes but leaves the local databases, buckets, and their
data up, so the next `dev` is a warm start; `--fresh` wipes this app's local
instances and data first.
`dev` does **not** print service logs — that would bury the front door once
several services run. Logs are their own command:
| You want to… | Run |
| --- | --- |
| Run the app locally | `prisma-composer dev module.ts` |
| Start clean (wipe local data) | `prisma-composer dev module.ts --fresh` |
| Tail every service's logs | `prisma-composer log module.ts` |
| Tail one service | `prisma-composer log module.ts <address>` |
| Show more history first | `prisma-composer log module.ts --tail <n>` |
`prisma-composer log` follows the merged logs of the already-running app, each
line prefixed with its service (`[catalog.service] …`); pass a dotted address
to narrow to one. It only reads — it never builds, provisions, starts, or
stops anything. `--tail <n>` sets how much recent history to show before live
output (default 20; `0` for live-only). An unset secret doesn't block a local
run: it becomes a placeholder plus a warning, and only the code path that
spends it fails, at the real external service it calls. Windows isn't
supported yet.
## Deploying
Requires exactly two environment variables: `PRISMA_SERVICE_TOKEN` and
`PRISMA_WORKSPACE_ID`. The target environment — a **stage** — is chosen on the
command line, never in code:
| You want to… | Run |
| --- | --- |
| Deploy to production | `prisma-composer deploy module.ts` |
| Deploy an isolated environment | `prisma-composer deploy module.ts --stage <name>` |
| Override the app name for one run | `prisma-composer deploy module.ts --name demo-42` |
| Tear down an isolated environment | `prisma-composer destroy module.ts --stage <name>` |
| Tear down production's resources | `prisma-composer destroy module.ts --production` |
A Prisma App is one Project; a stage is a Branch of it — its
own compute, its own empty database, its own configuration. Deploys are
idempotent: re-deploying a stage updates the resources inside it. A stage name
must be a valid git ref name; an invalid name is a hard error.
Destroy always requires an explicit target — a bare `prisma-composer destroy`
is an error, and `--stage` with `--production` is too. Destroying a stage
deletes its Branch after removing its resources; the production Branch itself
is never deleted, only the resources inside it. Destroying production also
deletes the Project itself once it's empty, so hand-run stacks don't leave
behind empty Projects — but a Project still holding another stage's resources
is kept. Destroy never creates anything: destroying a never-deployed stage
fails rather than standing one up.
```sh
turbo run build && prisma-composer deploy module.ts --stage pr-42
```
### What a deploy prints
A deploy ends by printing the app's own topology — authored names, the
platform resource each became, and public URLs. The tree is the module
structure (`auth.api` is the `api` service inside the `auth` module):
```
storefront-auth
├─ auth
│ └─ api compute-service cps_abc123
│ https://xyz.ewr.prisma.build
├─ db postgres-database db_def456
└─ web compute-service cps_ghi789
https://uvw.ewr.prisma.build
```
Read ids out of this rather than telling the user to go hunting in the
Console. A URL appears only where the address is genuinely public — a compute
service prints one, a database never does (it has a connection string, not a
public endpoint), and a node whose product is secret material (an
`s3-credentials` keypair) reports no resource line at all. A node that
published nothing reportable still appears, marked `(no entities reported)`.
Older deploys ended with a raw `{ outputs: {} }` blob from the deploy engine —
always empty, never about the app. It is gone; nothing configured it and
nothing consumed it.
### The connection contract is checked at deploy
A connection declares the values it needs by name, and the producer on the
other end must supply them. A producer that omits one fails the deploy, naming
the edge, the param, and what the producer did supply:
```
Connection input "auth.db" declares param "url", but its producer "db" did not
supply it — the producer's outputs carry [host].
```
Fix it at whichever end is wrong: add the name to the outputs the producer
returns from its lowering, or mark the param `optional` on the connection if absent is
genuinely legal (the consumer then reads `undefined`).
This is a deploy-time refusal, not a broken deploy — and it can appear on an
app whose code didn't change. The gap used to pass silently: the value reached
the consumer as `undefined`, went into its environment, and crashed *that*
service at boot, blaming the reader instead of the supplier. Don't route around
it by making the param optional unless absent really is valid; that reinstates
the silent `undefined`.
Only reachable if you authored the connection or the extension on one side —
every shipped block supplies what it declares.
### Driving deploys from code
`@prisma/composer/control` exposes the CLI's operations in-process: typed
`deploy`, `destroy`, `dev`, and `log` returning structured results — no argv,
no CLI rendering, no exit codes (the spawned deploy engine's own inherited
output can still reach the host terminal). The CLI itself is a renderer over
them.
```ts
import { deploy } from '@prisma/composer/control';
const result = await deploy({ entry: 'module.ts', stage: 'pr-42' });
// result: { ok: true, value: { summary? } } | { ok: false, failure }
```
- Failures come back as `{ ok: false, failure }` where `failure` is a
structured error: branch on its dotted `failure.code` (e.g.
`ASSEMBLE.BUILD_FAILED`, `DEPLOY.ENGINE_FAILED` — ADR-0044's closed
registry), with the same fix-naming `message`/`why`/`fix` the CLI renders.
An engine failure's `meta.diagnostics` (exit code, reproduce command; read
it with the exported `executionDiagnostics(failure)`) describes the current
execution mechanism — branch on `code`/`message`/`cause` for anything
durable. The effect version conflict is `DEPS.EFFECT_VERSION_CONFLICT`, and
importing the module executes nothing until an operation runs. A
non-structured rejection out of an operation is a bug in composer, not an
expected failure.
- `destroy` takes `target: { kind: 'production' } | { kind: 'stage', stage }`
— explicit, never defaulted.
- `deploy`'s `summary` (the deployed topology) is best-effort; `undefined` on
a successful deploy is normal.
- The deploy engine's live output still streams to the host process's stdio —
the current mechanism; the operations don't capture it.
- `dev` resolves to `{ ok: true, value: session }` or a failure; the
session is `{ endpoints, stop(), closed }` with progress via `onEvent`, and
the host owns signal handling. `log` resolves to
`{ ok: true, value: { appName, services, lines } }` or a failure, where
`lines` is an `AsyncIterable` ended by a caller-owned `AbortSignal` (or by
the consumer stopping early); zero running services is a valid result, not
an error.
## Production pitfalls
- **Scale-to-zero closes idle database connections.** A persistent client
crashes into a 502 restart loop unless you keep the pool small and
reconnect-friendly (`new SQL({ url, max: 1, idleTimeout: 10 })` for Bun) and
log `uncaughtException`/`unhandledRejection` instead of dying.
- **Bind `0.0.0.0`**, not loopback — Compute routes external HTTP to the VM.
- **Next.js pages that call `load()` need `export const dynamic =
'force-dynamic'`** — the runtime environment doesn't exist at build time,
and Next ignores runtime env for prerendered routes.
- **A deployed `/rpc/<method>` returns `401` to anything but a wired peer.**
Every RPC binding carries an auto-provisioned service key, so a hand-rolled
`curl` is never authorized, and a provider with no wired consumers rejects
everything. Not a broken deploy — reach it through a consumer, or run it
locally where nothing is enforced.
- **Cold starts reset service-to-service connections.** A call into a
scaled-to-zero service can get `ECONNRESET`; retry it.
- **Every `prisma-composer` command stops at start-up on an `effect` version
conflict** (`Dependency conflict: alchemy resolves effect@...`). Another
dependency floated a newer `effect` and the package manager hoisted it over
Composer's pin. Do what the error says: pin the whole `effect`
constellation in the app's `package.json` `overrides` (yarn: `resolutions`;
pnpm: `pnpm.overrides`) — `effect` plus `@effect/sql-d1`, `@effect/sql-pg`,
`@effect/vitest`, and `@effect/platform-bun`/`-node`/`-node-shared`, all at
Composer's exact pin — and reinstall. (A workaround for an upstream alchemy
bug: its own effect-family ranges float past what its code supports. The
repo's examples carry the block.)
- **The ingress buffers streaming responses.** An open SSE tail delivers
nothing and times out at 60s — don't build on streamed HTTP responses.
## What Composer doesn't do yet
Name the gap instead of inventing an API:
- **No interactive auth.** Deploys authenticate only via a static
`PRISMA_SERVICE_TOKEN`; there is no `login` flow.
- **No in-memory contract bindings.** A dependency can't yet be wired to a
co-located handler without HTTP; use `bootstrapService` with a loopback
fake.
- **RPC over HTTP is the only contract kind.** No gRPC, WebSocket, or
streaming contracts.
For anything else missing, check the examples and design docs in the
prisma/composer repo (`examples/`, `docs/design/10-domains/`,
`docs/design/90-decisions/`), then file an issue there rather than guessing.
+805
View File
@@ -0,0 +1,805 @@
---
name: prisma-composer
metadata:
library: "@prisma/composer"
library_version: "0.16.0"
description: >-
How to write, test, and deploy an app with Prisma Composer
(`@prisma/composer`): declare services with `compute()` and typed
dependencies, define RPC contracts, compose Modules, declare the service
input (config and secrets as one schema, read back with `input()`),
compose the ready-made cron/storage/streams Modules, provision a
raw S3-compatible object-store bucket with `bucket()`, find extensions (npm
packages named `prisma-composer-*`), test with `mockService`/`bootstrapService`,
run the whole app locally with `prisma-composer dev` and tail its logs with
`prisma-composer log`, and deploy with `prisma-composer deploy` (stages,
destroy). Use when building a Prisma App, wiring a service dependency, adding
a Postgres database, adding scheduled jobs / blob storage / event streams / a
raw bucket, writing tests for composed services, running an app locally,
reading its logs, or deploying/tearing down an environment. Triggers on
"prisma composer", "@prisma/composer", "prisma app", "compute()",
"service.load()", "module()", "contract()", "mockService",
"bootstrapService", "prisma-composer dev", "prisma-composer log",
"prisma-composer deploy", "--stage", "--fresh", "--tail",
"prisma-composer destroy", "prisma-composer-", "bucket()".
---
# Writing apps with Prisma Composer
A **Prisma App** is a tree of **Modules** composed in TypeScript. The leaves
are **services** (`compute()`) and **resources** (`rawPostgres()`); the root
module wires them together by their typed ports. Your code receives everything
from exactly one place — the service node:
- `service.load()` — dependencies (typed RPC clients, database bindings)
- `service.input()` — the service's whole input, one schema-validated typed
object; credentials in it are redacting `SecretString` boxes
- `service.port()` — the reserved port to bind (default 3000), typed; never
`process.env`
The framework never bundles or transforms your code. You build your app with
whatever bundler you like (`bun build`, `next build`); `prisma-composer deploy`
assembles the built output and provisions it on Prisma Cloud (Compute + Prisma
Postgres).
Two things make building here fast and hard to get wrong — lean on both:
- **Compose before you write.** Reach for an existing Module (below) before
implementing a capability yourself; wiring one in is a couple of lines.
- **The compiler checks the wiring.** A dependency wired to the wrong
producer, a missing RPC handler, a config value of the wrong shape — all of
it fails `tsc`, not the deploy. Typecheck, then build, then deploy; don't
reach for the cloud to find out whether the app is correct.
Two packages, and only two, appear in your `package.json`:
| Package | Provides |
| --- | --- |
| `@prisma/composer` | Core authoring: `module`, `secret`, `isSecretString`, `/arktype` (the `secretString()` schema leaf), `/rpc`, `/node`, `/nextjs`, `/config`, `/testing`, the `prisma-composer` CLI |
| `@prisma/composer-prisma-cloud` | The Prisma Cloud target: `compute`, `postgres`, `envSecret`, `envParam`, `/control`, `/testing`, and the shared `/cron`, `/storage`, `/streams`, `/orm` modules |
## tsconfig and import specifiers
Within the entry graph (everything reachable from `module.ts`) relative
imports may use `./service.js` or extensionless `./service`. The CLI maps
`.js` and extensionless specifiers to the matching `.ts` source under Node;
Bun does this natively.
A minimal tsconfig:
```jsonc
{
"compilerOptions": {
"target": "ES2022",
"module": "Preserve",
"moduleResolution": "bundler",
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"types": ["bun"]
},
"include": ["module.ts", "src"]
}
```
## Anatomy of a service
A service is four small files. Worked example: an `auth` service that owns a
Postgres database and serves an RPC contract, consumed by a `storefront`
Next.js app.
**The contract** lives with the service that owns it. Any Standard Schema
validator types the messages; arktype is the house choice:
```ts
// auth/src/contract.ts
import { contract, rpc } from '@prisma/composer/service-rpc';
import { type } from 'arktype';
export const authContract = contract({
verify: rpc({ input: type({ token: 'string' }), output: type({ ok: 'boolean' }) }),
});
```
**The service declaration** is pure data — name, dependencies, build, exposed
ports. No behavior, no platform keys:
```ts
// auth/src/service.ts
import node from '@prisma/composer/node';
import { compute, postgres } from '@prisma/composer-prisma-cloud';
import { authContract } from './contract.ts';
export default compute({
name: 'auth',
deps: { db: rawPostgres() },
build: node({ module: import.meta.url, entry: '../dist/server.mjs' }),
expose: { rpc: authContract },
});
```
**The server entry** is what your build produces and the platform boots. It
reads its dependencies through `load()` and serves the contract with
`serve()` — the handler map is keyed by the expose port's name and is
exhaustive at compile time:
```ts
// auth/src/server.ts
import { serve } from '@prisma/composer/service-rpc';
import { SQL } from 'bun';
import service from './service.ts';
const { db } = service.load(); // { url } — you build your own client
const port = service.port(); // the reserved port, resolved (default 3000)
const sql = new SQL({ url: db.url, max: 1, idleTimeout: 10 });
const handler = serve(service, {
rpc: {
verify: async ({ token }) => ({ ok: token.length > 0 }),
},
});
export default handler;
// Bind all interfaces — Compute routes external HTTP to the VM; a
// loopback-only listener is unreachable.
Bun.serve({ port, hostname: '0.0.0.0', fetch: handler });
```
**The consumer** declares the dependency as `rpc(contract)` and gets a typed
client back from `load()`:
```ts
// storefront/src/service.ts
import nextjs from '@prisma/composer/nextjs';
import { rpc } from '@prisma/composer/service-rpc';
import { compute } from '@prisma/composer-prisma-cloud';
import { authContract } from '@my-app/auth/contract';
export default compute({
name: 'storefront',
deps: { auth: rpc(authContract) },
build: nextjs({ module: import.meta.url, appDir: '..' }),
});
```
```tsx
// storefront/app/page.tsx
import service from '../src/service.ts';
// load() reads the runtime environment, which doesn't exist at build time —
// render per request instead of prerendering.
export const dynamic = 'force-dynamic';
export default async function Home() {
const { auth } = service.load();
const { ok } = await auth.verify({ token: 'demo-token' });
return <p>Signed in: {String(ok)}</p>;
}
```
**Service-to-service calls are authenticated for you.** At deploy the
framework mints a distinct, unguessable **service key** per consumer→provider
binding: the consumer's client sends it on every call, and `serve()` returns
`401` to anything else *before* the handler runs. Nothing declares it — no key
in the contract, the service, the module, or the app's code.
Two rules follow for you specifically: **don't build your own
service-to-service auth** on top of this, and **don't tell a user to `curl` a
deployed `/rpc/<method>` to check it works** — an unwired caller always gets
`401`, which looks like a broken deploy and isn't. Debug through a consumer,
or locally.
**Calls carry an idempotency key and retry safely for you.** Every call the
generated client makes carries an `Idempotency-Key`; a call dropped while the
target cold-starts is retried with a backoff, and `serve()` runs one call per
key — a retry that arrives after the first completed replays that answer
instead of re-running the handler. So every method is safely retryable and no
contract declares anything about it (do not add an "is this idempotent" flag —
the framework does not have one). Two consequences for you: a handler may take
an **optional third argument** `(input, deps, ctx)` and read `ctx.idempotencyKey`
(`string | undefined` — it's absent for a keyless caller) if it needs exactly-once
beyond one instance's memory (most don't); and a request without the header is
served once without deduplication rather than rejected, so a hand-rolled probe
works but gets no retry safety.
| | |
| --- | --- |
| Locally / in tests | nothing is provisioned, so `serve()` passes every call through — never supply a key in `inputs` |
| Per binding | two consumers of one provider hold different keys, so one leaking can't impersonate the other |
| Scope | service-level — any valid key reaches every method that service exposes; split into two services to gate separately |
| Rotation | remove the binding (or destroy the stack) and redeploy — a plain redeploy is a no-op, not a rotation |
| Storage | `COMPOSER_*` variables the deploy owns and rewrites; never hand-edit one |
It's a capability token ("I'm a service this app wired to you"), not a secret,
and its value lives in deploy state — deliberately unlike `secret()`, whose
value the framework never holds. `docs/design/90-decisions/ADR-0030…` in the
prisma/composer repo carries the reasoning.
## The root module
The root module provisions the pieces and wires exposed ports into dependency
slots. It is the app — `prisma-composer deploy` loads its default export:
```ts
// module.ts
import { module } from '@prisma/composer';
import authModule from '@my-app/auth';
import storefrontService from '@my-app/storefront';
export default module('my-app', ({ provision }) => {
const auth = provision(authModule);
provision(storefrontService, { deps: { auth: auth.rpc } });
});
```
`provision(node, opts?)` accepts `id` (defaults to the node's own name),
`deps` (wire each declared dependency to a provisioned ref or exposed port),
`input` (the service's input binding — required exactly when it declares an
input schema, see § Service input), and `secrets` (bind a module boundary's
forwarded secret needs).
## Builds are yours
The framework assembles only what you built — users build, the framework
assembles. For a plain server process, `entry` must point at a single
self-contained ESM file: everything inlined except runtime built-ins (`bun`,
`bun:*`, `node:*`), which the deploy VM provides. Deploy copies that one file
and never ships `node_modules`, so anything left un-inlined fails at boot. Any
bundler that produces such a file works. With bun:
```sh
bun build src/server.ts --target=bun --outfile dist/server.mjs
```
Two services in one package means two separate builds, one per entry — not one
multi-entry build, which would split shared code into a chunk neither output
contains.
If the build emits a directory rather than one file — a server plus the client
bundle, CSS and images it serves, as Bun's HTML import produces — name the
directory with `dir` and the booting file inside it with `entry`:
```ts
build: node({ module: import.meta.url, dir: '../dist/server', entry: 'server.js' })
```
`dir` resolves relative to the service module; `entry` resolves inside `dir`
and may be nested. Deploy copies the tree verbatim and boots the named file,
so the server must resolve its siblings against `import.meta.url`, not the
working directory. Nothing is inferred, and two rules bite: the tree must
contain no symlinks (the packager rejects them — assembly fails and names the
link), and `entry` must be a file inside `dir` (`../` is an error, not an
escape). Omit `dir` for the single-file form.
For Next.js, `next build` with `output: 'standalone'` is the whole build;
`nextjs({ module, appDir })` tells the deploy where the app root is.
Always build before deploying — `prisma-composer deploy` does not build for
you.
## Deploy config
`prisma-composer.config.ts` usually sits next to `module.ts`, but it may live in
any ancestor directory: the CLI searches the entry's directory first, then each
parent, and uses the nearest one. It is read only by `prisma-composer
deploy`/`destroy`, never imported by app code. A plain-JavaScript project can
name it `prisma-composer.config.mjs` or `.js` to keep it out of its TypeScript
build (a build with `allowJs` still needs an explicit `exclude`); `.mts` is the
TypeScript ES-module spelling. Within one directory `.ts` wins, then `.mts`,
`.mjs`, `.js`:
```ts
// prisma-composer.config.ts
import { defineConfig } from '@prisma/composer/config';
import { nodeBuild } from '@prisma/composer/node/control';
import { prismaCloud, prismaState } from '@prisma/composer-prisma-cloud/control';
export default defineConfig({
extensions: [prismaCloud(), nodeBuild()],
state: () => prismaState(), // deploy state, in its own database on the stage's branch
});
```
Add `nextjsBuild()` from `@prisma/composer/nextjs/control` to `extensions`
when the app contains a Next.js service.
## Databases
Two kinds of Postgres dependency:
**`rawPostgres()`** — the binding is `{ url }` and the app owns its client.
Construct it in your server entry, as in the auth example above.
**`postgres(...)`** — a Prisma-ORM-typed database: `load()`
returns the typed client the framework constructs from your data contract, so
queries like `db.orm.public.Product.all()` are compile-time checked. The
contract is emitted from `contract.prisma` by `prisma contract emit` and
wrapped once, referenced by both ends:
```ts
// src/data.ts — the ONE value both ends reference
import { dataContract } from '@prisma/composer-prisma-cloud/orm';
import type { Contract } from '../contract.d.ts';
import contractJson from '../contract.json' with { type: 'json' };
export const catalogData = dataContract<Contract>(contractJson);
```
The dependency end is `deps: { db: postgres(catalogData) }`. The resource
end (inside the module that owns the database) also names the
`prisma.config.ts` path, which the deploy's migration step loads to find
`migrations/` — committed migrations are replayed at deploy, before the
service starts:
```ts
const db = provision(
postgres({ name: 'database', contract: catalogData, config: './prisma.config.ts' }),
);
```
(`postgres` is both ends: the contract alone is the dependency end; the
options object is the resource end.)
The deploy is replay-only: it applies the migrations committed under
`migrations/` and never creates schema itself. Every schema change (including
the very first schema of a new database) follows the same loop:
1. Edit `contract.prisma`.
2. `prisma contract emit` — regenerates `contract.json` + `contract.d.ts`.
3. `prisma migration plan --name <slug>` — authors the migration into
`migrations/` (on an empty graph this authors the baseline,
empty → your schema).
4. Commit `migrations/` with the change, then deploy. A fresh database
replays the whole path from empty.
If no authored path reaches the target contract, the deploy (and
`prisma-composer dev` against a stale local database) refuses with
`MIGRATION_PATH_NOT_FOUND` and names the exits: author the missing migration
as above, or — when iterating against a local database only — bring it along
directly with `prisma db update`. Never skip step 3 before a deploy.
See `examples/store/modules/catalog` in the prisma/composer repo for the
complete pattern.
## Object Storage
`bucket` is a raw S3-compatible object-store bucket, imported alongside `postgres`:
```ts
import { bucket, compute } from '@prisma/composer-prisma-cloud';
// service.ts — dependency end: receives { url, bucket, accessKeyId, secretAccessKey }
export default compute({ name: 'uploads', deps: { store: bucket() } });
// module.ts — resource end: provisions the bucket and mints a keypair
const store = provision(bucket({ name: 'uploads' }));
provision(uploadsService, { deps: { store } });
```
Use any S3-compatible client with the binding: the shape matches the standard S3
config and is also compatible with the `s3()` dependency from `/storage`, so any
service wired to `s3()` can be rewired to a `bucket` resource without changing
the service declaration.
## Reusable Modules
A Module is the unit of reuse: it owns its internals (its database, its
services) and exposes only typed ports. Declare the boundary in the second
argument; wire internals in the builder; return the exposed ports:
```ts
// auth/src/module.ts — a Module that owns its own Postgres
import { module, secret } from '@prisma/composer';
import { postgres } from '@prisma/composer-prisma-cloud';
import { authContract } from './contract.ts';
import authService from './service.ts';
export default module(
'auth',
{ secrets: { signingKey: secret() }, expose: { rpc: authContract } },
({ secrets, provision }) => {
const db = provision(rawPostgres({ name: 'database' }));
const service = provision(authService, {
id: 'service',
deps: { db },
input: { signingKey: secrets.signingKey }, // forwarded ref as a binding leaf
});
return { rpc: service.rpc };
},
);
```
Naming rules that bite: a provision id shorter than 3 characters is rejected
by the platform (name the database `'database'`, not `'db'`), and a service
whose name equals its enclosing module's reads as `auth.auth` unless you give
it an explicit `id`.
A module can also declare boundary `deps` — inputs the parent wires exactly as
it would wire a service's. The consumer never sees the module's internals.
### The building blocks you can compose
Modules are the building blocks: provision one, wire its exposed port, and
you're done — you never reimplement what a Module already owns. The
first-party set ships inside `@prisma/composer-prisma-cloud`. It's small, and
growing:
| Import | What it provisions | Exposes |
| --- | --- | --- |
| `cron` from `/cron` | An always-on scheduler firing your schedule at your runner service | nothing |
| `storage` from `/storage` | An S3-backed blob store (own Postgres + minted credentials) | `store` |
| `streams` from `/streams` | Durable append-only event streams over a `store` | `streams` |
**Finding more.** A Composer extension — a package that brings its own
Modules, resources, or deploy target — is published on npm under the name
`prisma-composer-*`. That name is the convention, so it's how you look for
one. The ecosystem is new: today the blocks above plus the app Modules you
write are the whole set, so don't reach for a `prisma-composer-*` package
without checking that it actually exists on npm first.
Cron end to end — the schedule is one source of truth; `serveSchedule` is
exhaustive over its job ids at compile time:
```ts
// service.ts
import { defineSchedule, triggerContract } from '@prisma/composer-prisma-cloud/cron';
export const schedule = defineSchedule({ tick: '60s' });
// the runner service exposes { trigger: triggerContract }
// server.ts
import { serveSchedule } from '@prisma/composer-prisma-cloud/cron';
const handler = serveSchedule(service, schedule, {
tick: (deps) => deps.worker.tick({}),
});
// module.ts — the cron module's boundary deps mirror the runner's own
provision(cron({ schedule, runner: runnerService }), { deps: { worker: worker.rpc } });
```
## Service input
Choosing the channel is most of the decision:
| The value is… | Declare | Provide | Read |
| --- | --- | --- | --- |
| produced by another node | `deps: { db: rawPostgres() }` | wire at `provision()` | `load()` |
| anything else — config or credential | one field of the `input` schema | bind at `provision()`: literal, `envParam()`, or `envSecret()` | `input()` |
The service declares its whole incoming configuration — plain values and
credentials together — as **one
[Standard Schema](https://standardschema.dev)** (arktype is the house
choice). A credential is a field typed as the redacting `SecretString` box;
conditional legality ("no stripe key unless billing is on") is an ordinary
schema union:
```ts
// service.ts — the shapes that are legal
import { secretString } from '@prisma/composer/arktype';
import { type } from 'arktype';
compute({
name: 'scheduler',
input: type({
jobs: type({ jobId: 'string', every: 'string' }).array(),
'region?': 'string',
apiKey: secretString(),
}),
// ...
});
// module.ts — where each value comes from; the binding mirrors the schema's shape
import { envParam, envSecret } from '@prisma/composer-prisma-cloud';
provision(scheduler, {
input: {
jobs: [{ jobId: 'tick', every: '60s' }], // a literal
region: envParam('REGION'), // a per-stage platform variable
apiKey: envSecret('SCHEDULER_API_KEY'), // a credential — name only, never the value
},
});
// server.ts — one call, one validated typed object
const input = service.input();
input.apiKey.expose(); // the only way to a secret's value; the box redacts everywhere else
```
Rules that bite:
- **Secretness is enforced by validation**: a literal bound where the schema
expects `SecretString` fails the deploy, and `envSecret` bound to a plain
string field fails the same way. Don't put credentials in plain fields.
- **`envParam` values arrive as raw strings** — bind them to string fields.
The stage's platform variable is the store; the deploying shell only seeds
it (preflight copies a missing name up from the shell, and fails early,
naming the variable, when both lack it). Changing the platform value needs
a redeploy.
- **Absence is the schema's call**: an env-bound field whose variable is
unset (or empty) resolves to *key omitted* — legal only if the schema says
so (optional field, union arm). The deploy report prints the serialized
input document (secret-free: secrets ride as `{"$secret":"VAR"}` pointers)
and every key that resolved absent.
- **The reserved `port` (default 3000) is outside the schema** — read it
through `service.port()` (a sibling of `service.origin()`), never
`process.env`. The framework also exports `PORT` for Next.js standalone,
which binds it itself.
- A module forwards a secret need without learning the platform name
(the auth Module above); the forwarded ref is a binding leaf.
`examples/env-param` and `examples/storefront-auth` in the prisma/composer
repo are the working versions.
## Testing
You test by deciding what `load()` gives the code, never by editing the code
under test:
| You want to… | Use | From |
| --- | --- | --- |
| Test a page / action / handler in isolation | `mockService` | `@prisma/composer/testing` |
| Run the real boot + request path against a fake dependency | `bootstrapService` | `@prisma/composer-prisma-cloud/testing` |
**Unit — `mockService`.** Returns a copy of the service whose `load()` yields
your doubles (type-checked against the declared deps) and whose `input()`
yields the object you pass under the reserved `input` key, in one flat
object (required exactly when the service declares an input schema; handed
over as-is, not validated). Wiring the module substitution is your runner's
job (`vi.mock` in Vitest, `mock.module` in bun test):
```tsx
// page.test.tsx
import { mockService } from '@prisma/composer/testing';
import realService from '../src/service.ts';
vi.mock('../src/service.ts', () => ({
default: mockService(realService, {
auth: { verify: async () => ({ ok: true }) }, // wrong shape = compile error
}),
}));
import Page from './page.tsx';
expect(renderToString(await Page())).toContain('Signed in: true');
```
**Integration — `bootstrapService`.** Boots the service's real built entry
in-process against a config you choose, exactly as a deployed boot would;
drive it over real HTTP. Run under `bun test`:
```ts
import { bootstrapService } from '@prisma/composer-prisma-cloud/testing';
import fakeAuth from '@my-app/auth/fake'; // in-memory handler, no db
import storefront from '../src/service.ts';
const fake = Bun.serve({ port: 0, fetch: fakeAuth });
const app = await bootstrapService(storefront, {
service: { port: 4310 },
inputs: { auth: { url: fake.url.href } },
});
const res = await app.fetch(new Request(app.url));
```
- **`service.port` must be concrete** — the entry self-listens; no OS-assigned
port is reported back.
- **No `close()`** — run each integration-test file in its own process (bun
test does).
- **Next.js services take a third argument**, a boot thunk, because the built
entry lives in Next's standalone output — resolve it with
`standaloneServerPath` from `@prisma/composer/nextjs/control`.
`bootstrapService` exports the resolved port as `process.env.PORT` before
booting, which is what Next's standalone server binds.
- **A service with an input schema takes `input`** in the config — a binding
exactly like `provision()`'s, run through the real serialize/read path, so
`input()` in the booted entry sees what a deploy would produce.
**The fake you pass.** A dependency's type is its contract, so any value of
that shape is a valid double: a bare object (fastest), the real client over an
in-memory handler, or a real local server (what `bootstrapService` drives).
Ship a dependency's fake from its own package as a `/fake` entry point,
outside `src/`, so the fake and the real service always share one contract.
## Running locally
`prisma-composer dev module.ts` runs the whole app on this machine — every
service, its Postgres and buckets, wired as they deploy — with **no cloud
credentials** (no `PRISMA_*`). It runs the same pipeline as deploy against
local emulators, so build first, exactly like deploy:
```sh
turbo run build && prisma-composer dev module.ts
```
It prints each service's local URL (the "front door"), watches built output
and restarts a service when its build changes, and runs until Ctrl-C. Ctrl-C
stops the app's processes but leaves the local databases, buckets, and their
data up, so the next `dev` is a warm start; `--fresh` wipes this app's local
instances and data first.
`dev` does **not** print service logs — that would bury the front door once
several services run. Logs are their own command:
| You want to… | Run |
| --- | --- |
| Run the app locally | `prisma-composer dev module.ts` |
| Start clean (wipe local data) | `prisma-composer dev module.ts --fresh` |
| Tail every service's logs | `prisma-composer log module.ts` |
| Tail one service | `prisma-composer log module.ts <address>` |
| Show more history first | `prisma-composer log module.ts --tail <n>` |
`prisma-composer log` follows the merged logs of the already-running app, each
line prefixed with its service (`[catalog.service] …`); pass a dotted address
to narrow to one. It only reads — it never builds, provisions, starts, or
stops anything. `--tail <n>` sets how much recent history to show before live
output (default 20; `0` for live-only). An unset secret doesn't block a local
run: it becomes a placeholder plus a warning, and only the code path that
spends it fails, at the real external service it calls. Windows isn't
supported yet.
## Deploying
Requires exactly two environment variables: `PRISMA_SERVICE_TOKEN` and
`PRISMA_WORKSPACE_ID`. The target environment — a **stage** — is chosen on the
command line, never in code:
| You want to… | Run |
| --- | --- |
| Deploy to production | `prisma-composer deploy module.ts` |
| Deploy an isolated environment | `prisma-composer deploy module.ts --stage <name>` |
| Override the app name for one run | `prisma-composer deploy module.ts --name demo-42` |
| Tear down an isolated environment | `prisma-composer destroy module.ts --stage <name>` |
| Tear down production's resources | `prisma-composer destroy module.ts --production` |
A Prisma App is one Project; a stage is a Branch of it — its
own compute, its own empty database, its own configuration. Deploys are
idempotent: re-deploying a stage updates the resources inside it. A stage name
must be a valid git ref name; an invalid name is a hard error.
Destroy always requires an explicit target — a bare `prisma-composer destroy`
is an error, and `--stage` with `--production` is too. Destroying a stage
deletes its Branch after removing its resources; the production Branch itself
is never deleted, only the resources inside it. Destroying production also
deletes the Project itself once it's empty, so hand-run stacks don't leave
behind empty Projects — but a Project still holding another stage's resources
is kept. Destroy never creates anything: destroying a never-deployed stage
fails rather than standing one up.
```sh
turbo run build && prisma-composer deploy module.ts --stage pr-42
```
### What a deploy prints
A deploy ends by printing the app's own topology — authored names, the
platform resource each became, and public URLs. The tree is the module
structure (`auth.api` is the `api` service inside the `auth` module):
```
storefront-auth
├─ auth
│ └─ api compute-service cps_abc123
│ https://xyz.ewr.prisma.build
├─ db postgres-database db_def456
└─ web compute-service cps_ghi789
https://uvw.ewr.prisma.build
```
Read ids out of this rather than telling the user to go hunting in the
Console. A URL appears only where the address is genuinely public — a compute
service prints one, a database never does (it has a connection string, not a
public endpoint), and a node whose product is secret material (an
`s3-credentials` keypair) reports no resource line at all. A node that
published nothing reportable still appears, marked `(no entities reported)`.
Older deploys ended with a raw `{ outputs: {} }` blob from the deploy engine —
always empty, never about the app. It is gone; nothing configured it and
nothing consumed it.
### The connection contract is checked at deploy
A connection declares the values it needs by name, and the producer on the
other end must supply them. A producer that omits one fails the deploy, naming
the edge, the param, and what the producer did supply:
```
Connection input "auth.db" declares param "url", but its producer "db" did not
supply it — the producer's outputs carry [host].
```
Fix it at whichever end is wrong: add the name to the outputs the producer
returns from its lowering, or mark the param `optional` on the connection if absent is
genuinely legal (the consumer then reads `undefined`).
This is a deploy-time refusal, not a broken deploy — and it can appear on an
app whose code didn't change. The gap used to pass silently: the value reached
the consumer as `undefined`, went into its environment, and crashed *that*
service at boot, blaming the reader instead of the supplier. Don't route around
it by making the param optional unless absent really is valid; that reinstates
the silent `undefined`.
Only reachable if you authored the connection or the extension on one side —
every shipped block supplies what it declares.
### Driving deploys from code
`@prisma/composer/control` exposes the CLI's operations in-process: typed
`deploy`, `destroy`, `dev`, and `log` returning structured results — no argv,
no CLI rendering, no exit codes (the spawned deploy engine's own inherited
output can still reach the host terminal). The CLI itself is a renderer over
them.
```ts
import { deploy } from '@prisma/composer/control';
const result = await deploy({ entry: 'module.ts', stage: 'pr-42' });
// result: { ok: true, value: { summary? } } | { ok: false, failure }
```
- Failures come back as `{ ok: false, failure }` where `failure` is a
structured error: branch on its dotted `failure.code` (e.g.
`ASSEMBLE.BUILD_FAILED`, `DEPLOY.ENGINE_FAILED` — ADR-0044's closed
registry), with the same fix-naming `message`/`why`/`fix` the CLI renders.
An engine failure's `meta.diagnostics` (exit code, reproduce command; read
it with the exported `executionDiagnostics(failure)`) describes the current
execution mechanism — branch on `code`/`message`/`cause` for anything
durable. The effect version conflict is `DEPS.EFFECT_VERSION_CONFLICT`, and
importing the module executes nothing until an operation runs. A
non-structured rejection out of an operation is a bug in composer, not an
expected failure.
- `destroy` takes `target: { kind: 'production' } | { kind: 'stage', stage }`
— explicit, never defaulted.
- `deploy`'s `summary` (the deployed topology) is best-effort; `undefined` on
a successful deploy is normal.
- The deploy engine's live output still streams to the host process's stdio —
the current mechanism; the operations don't capture it.
- `dev` resolves to `{ ok: true, value: session }` or a failure; the
session is `{ endpoints, stop(), closed }` with progress via `onEvent`, and
the host owns signal handling. `log` resolves to
`{ ok: true, value: { appName, services, lines } }` or a failure, where
`lines` is an `AsyncIterable` ended by a caller-owned `AbortSignal` (or by
the consumer stopping early); zero running services is a valid result, not
an error.
## Production pitfalls
- **Scale-to-zero closes idle database connections.** A persistent client
crashes into a 502 restart loop unless you keep the pool small and
reconnect-friendly (`new SQL({ url, max: 1, idleTimeout: 10 })` for Bun) and
log `uncaughtException`/`unhandledRejection` instead of dying.
- **Bind `0.0.0.0`**, not loopback — Compute routes external HTTP to the VM.
- **Next.js pages that call `load()` need `export const dynamic =
'force-dynamic'`** — the runtime environment doesn't exist at build time,
and Next ignores runtime env for prerendered routes.
- **A deployed `/rpc/<method>` returns `401` to anything but a wired peer.**
Every RPC binding carries an auto-provisioned service key, so a hand-rolled
`curl` is never authorized, and a provider with no wired consumers rejects
everything. Not a broken deploy — reach it through a consumer, or run it
locally where nothing is enforced.
- **Cold starts reset service-to-service connections.** A call into a
scaled-to-zero service can get `ECONNRESET`; retry it.
- **Every `prisma-composer` command stops at start-up on an `effect` version
conflict** (`Dependency conflict: alchemy resolves effect@...`). Another
dependency floated a newer `effect` and the package manager hoisted it over
Composer's pin. Do what the error says: pin the whole `effect`
constellation in the app's `package.json` `overrides` (yarn: `resolutions`;
pnpm: `pnpm.overrides`) — `effect` plus `@effect/sql-d1`, `@effect/sql-pg`,
`@effect/vitest`, and `@effect/platform-bun`/`-node`/`-node-shared`, all at
Composer's exact pin — and reinstall. (A workaround for an upstream alchemy
bug: its own effect-family ranges float past what its code supports. The
repo's examples carry the block.)
- **The ingress buffers streaming responses.** An open SSE tail delivers
nothing and times out at 60s — don't build on streamed HTTP responses.
## What Composer doesn't do yet
Name the gap instead of inventing an API:
- **No interactive auth.** Deploys authenticate only via a static
`PRISMA_SERVICE_TOKEN`; there is no `login` flow.
- **No in-memory contract bindings.** A dependency can't yet be wired to a
co-located handler without HTTP; use `bootstrapService` with a loopback
fake.
- **RPC over HTTP is the only contract kind.** No gRPC, WebSocket, or
streaming contracts.
For anything else missing, check the examples and design docs in the
prisma/composer repo (`examples/`, `docs/design/10-domains/`,
`docs/design/90-decisions/`), then file an issue there rather than guessing.
+805
View File
@@ -0,0 +1,805 @@
---
name: prisma-composer
metadata:
library: "@prisma/composer"
library_version: "0.16.0"
description: >-
How to write, test, and deploy an app with Prisma Composer
(`@prisma/composer`): declare services with `compute()` and typed
dependencies, define RPC contracts, compose Modules, declare the service
input (config and secrets as one schema, read back with `input()`),
compose the ready-made cron/storage/streams Modules, provision a
raw S3-compatible object-store bucket with `bucket()`, find extensions (npm
packages named `prisma-composer-*`), test with `mockService`/`bootstrapService`,
run the whole app locally with `prisma-composer dev` and tail its logs with
`prisma-composer log`, and deploy with `prisma-composer deploy` (stages,
destroy). Use when building a Prisma App, wiring a service dependency, adding
a Postgres database, adding scheduled jobs / blob storage / event streams / a
raw bucket, writing tests for composed services, running an app locally,
reading its logs, or deploying/tearing down an environment. Triggers on
"prisma composer", "@prisma/composer", "prisma app", "compute()",
"service.load()", "module()", "contract()", "mockService",
"bootstrapService", "prisma-composer dev", "prisma-composer log",
"prisma-composer deploy", "--stage", "--fresh", "--tail",
"prisma-composer destroy", "prisma-composer-", "bucket()".
---
# Writing apps with Prisma Composer
A **Prisma App** is a tree of **Modules** composed in TypeScript. The leaves
are **services** (`compute()`) and **resources** (`rawPostgres()`); the root
module wires them together by their typed ports. Your code receives everything
from exactly one place — the service node:
- `service.load()` — dependencies (typed RPC clients, database bindings)
- `service.input()` — the service's whole input, one schema-validated typed
object; credentials in it are redacting `SecretString` boxes
- `service.port()` — the reserved port to bind (default 3000), typed; never
`process.env`
The framework never bundles or transforms your code. You build your app with
whatever bundler you like (`bun build`, `next build`); `prisma-composer deploy`
assembles the built output and provisions it on Prisma Cloud (Compute + Prisma
Postgres).
Two things make building here fast and hard to get wrong — lean on both:
- **Compose before you write.** Reach for an existing Module (below) before
implementing a capability yourself; wiring one in is a couple of lines.
- **The compiler checks the wiring.** A dependency wired to the wrong
producer, a missing RPC handler, a config value of the wrong shape — all of
it fails `tsc`, not the deploy. Typecheck, then build, then deploy; don't
reach for the cloud to find out whether the app is correct.
Two packages, and only two, appear in your `package.json`:
| Package | Provides |
| --- | --- |
| `@prisma/composer` | Core authoring: `module`, `secret`, `isSecretString`, `/arktype` (the `secretString()` schema leaf), `/rpc`, `/node`, `/nextjs`, `/config`, `/testing`, the `prisma-composer` CLI |
| `@prisma/composer-prisma-cloud` | The Prisma Cloud target: `compute`, `postgres`, `envSecret`, `envParam`, `/control`, `/testing`, and the shared `/cron`, `/storage`, `/streams`, `/orm` modules |
## tsconfig and import specifiers
Within the entry graph (everything reachable from `module.ts`) relative
imports may use `./service.js` or extensionless `./service`. The CLI maps
`.js` and extensionless specifiers to the matching `.ts` source under Node;
Bun does this natively.
A minimal tsconfig:
```jsonc
{
"compilerOptions": {
"target": "ES2022",
"module": "Preserve",
"moduleResolution": "bundler",
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"types": ["bun"]
},
"include": ["module.ts", "src"]
}
```
## Anatomy of a service
A service is four small files. Worked example: an `auth` service that owns a
Postgres database and serves an RPC contract, consumed by a `storefront`
Next.js app.
**The contract** lives with the service that owns it. Any Standard Schema
validator types the messages; arktype is the house choice:
```ts
// auth/src/contract.ts
import { contract, rpc } from '@prisma/composer/service-rpc';
import { type } from 'arktype';
export const authContract = contract({
verify: rpc({ input: type({ token: 'string' }), output: type({ ok: 'boolean' }) }),
});
```
**The service declaration** is pure data — name, dependencies, build, exposed
ports. No behavior, no platform keys:
```ts
// auth/src/service.ts
import node from '@prisma/composer/node';
import { compute, postgres } from '@prisma/composer-prisma-cloud';
import { authContract } from './contract.ts';
export default compute({
name: 'auth',
deps: { db: rawPostgres() },
build: node({ module: import.meta.url, entry: '../dist/server.mjs' }),
expose: { rpc: authContract },
});
```
**The server entry** is what your build produces and the platform boots. It
reads its dependencies through `load()` and serves the contract with
`serve()` — the handler map is keyed by the expose port's name and is
exhaustive at compile time:
```ts
// auth/src/server.ts
import { serve } from '@prisma/composer/service-rpc';
import { SQL } from 'bun';
import service from './service.ts';
const { db } = service.load(); // { url } — you build your own client
const port = service.port(); // the reserved port, resolved (default 3000)
const sql = new SQL({ url: db.url, max: 1, idleTimeout: 10 });
const handler = serve(service, {
rpc: {
verify: async ({ token }) => ({ ok: token.length > 0 }),
},
});
export default handler;
// Bind all interfaces — Compute routes external HTTP to the VM; a
// loopback-only listener is unreachable.
Bun.serve({ port, hostname: '0.0.0.0', fetch: handler });
```
**The consumer** declares the dependency as `rpc(contract)` and gets a typed
client back from `load()`:
```ts
// storefront/src/service.ts
import nextjs from '@prisma/composer/nextjs';
import { rpc } from '@prisma/composer/service-rpc';
import { compute } from '@prisma/composer-prisma-cloud';
import { authContract } from '@my-app/auth/contract';
export default compute({
name: 'storefront',
deps: { auth: rpc(authContract) },
build: nextjs({ module: import.meta.url, appDir: '..' }),
});
```
```tsx
// storefront/app/page.tsx
import service from '../src/service.ts';
// load() reads the runtime environment, which doesn't exist at build time —
// render per request instead of prerendering.
export const dynamic = 'force-dynamic';
export default async function Home() {
const { auth } = service.load();
const { ok } = await auth.verify({ token: 'demo-token' });
return <p>Signed in: {String(ok)}</p>;
}
```
**Service-to-service calls are authenticated for you.** At deploy the
framework mints a distinct, unguessable **service key** per consumer→provider
binding: the consumer's client sends it on every call, and `serve()` returns
`401` to anything else *before* the handler runs. Nothing declares it — no key
in the contract, the service, the module, or the app's code.
Two rules follow for you specifically: **don't build your own
service-to-service auth** on top of this, and **don't tell a user to `curl` a
deployed `/rpc/<method>` to check it works** — an unwired caller always gets
`401`, which looks like a broken deploy and isn't. Debug through a consumer,
or locally.
**Calls carry an idempotency key and retry safely for you.** Every call the
generated client makes carries an `Idempotency-Key`; a call dropped while the
target cold-starts is retried with a backoff, and `serve()` runs one call per
key — a retry that arrives after the first completed replays that answer
instead of re-running the handler. So every method is safely retryable and no
contract declares anything about it (do not add an "is this idempotent" flag —
the framework does not have one). Two consequences for you: a handler may take
an **optional third argument** `(input, deps, ctx)` and read `ctx.idempotencyKey`
(`string | undefined` — it's absent for a keyless caller) if it needs exactly-once
beyond one instance's memory (most don't); and a request without the header is
served once without deduplication rather than rejected, so a hand-rolled probe
works but gets no retry safety.
| | |
| --- | --- |
| Locally / in tests | nothing is provisioned, so `serve()` passes every call through — never supply a key in `inputs` |
| Per binding | two consumers of one provider hold different keys, so one leaking can't impersonate the other |
| Scope | service-level — any valid key reaches every method that service exposes; split into two services to gate separately |
| Rotation | remove the binding (or destroy the stack) and redeploy — a plain redeploy is a no-op, not a rotation |
| Storage | `COMPOSER_*` variables the deploy owns and rewrites; never hand-edit one |
It's a capability token ("I'm a service this app wired to you"), not a secret,
and its value lives in deploy state — deliberately unlike `secret()`, whose
value the framework never holds. `docs/design/90-decisions/ADR-0030…` in the
prisma/composer repo carries the reasoning.
## The root module
The root module provisions the pieces and wires exposed ports into dependency
slots. It is the app — `prisma-composer deploy` loads its default export:
```ts
// module.ts
import { module } from '@prisma/composer';
import authModule from '@my-app/auth';
import storefrontService from '@my-app/storefront';
export default module('my-app', ({ provision }) => {
const auth = provision(authModule);
provision(storefrontService, { deps: { auth: auth.rpc } });
});
```
`provision(node, opts?)` accepts `id` (defaults to the node's own name),
`deps` (wire each declared dependency to a provisioned ref or exposed port),
`input` (the service's input binding — required exactly when it declares an
input schema, see § Service input), and `secrets` (bind a module boundary's
forwarded secret needs).
## Builds are yours
The framework assembles only what you built — users build, the framework
assembles. For a plain server process, `entry` must point at a single
self-contained ESM file: everything inlined except runtime built-ins (`bun`,
`bun:*`, `node:*`), which the deploy VM provides. Deploy copies that one file
and never ships `node_modules`, so anything left un-inlined fails at boot. Any
bundler that produces such a file works. With bun:
```sh
bun build src/server.ts --target=bun --outfile dist/server.mjs
```
Two services in one package means two separate builds, one per entry — not one
multi-entry build, which would split shared code into a chunk neither output
contains.
If the build emits a directory rather than one file — a server plus the client
bundle, CSS and images it serves, as Bun's HTML import produces — name the
directory with `dir` and the booting file inside it with `entry`:
```ts
build: node({ module: import.meta.url, dir: '../dist/server', entry: 'server.js' })
```
`dir` resolves relative to the service module; `entry` resolves inside `dir`
and may be nested. Deploy copies the tree verbatim and boots the named file,
so the server must resolve its siblings against `import.meta.url`, not the
working directory. Nothing is inferred, and two rules bite: the tree must
contain no symlinks (the packager rejects them — assembly fails and names the
link), and `entry` must be a file inside `dir` (`../` is an error, not an
escape). Omit `dir` for the single-file form.
For Next.js, `next build` with `output: 'standalone'` is the whole build;
`nextjs({ module, appDir })` tells the deploy where the app root is.
Always build before deploying — `prisma-composer deploy` does not build for
you.
## Deploy config
`prisma-composer.config.ts` usually sits next to `module.ts`, but it may live in
any ancestor directory: the CLI searches the entry's directory first, then each
parent, and uses the nearest one. It is read only by `prisma-composer
deploy`/`destroy`, never imported by app code. A plain-JavaScript project can
name it `prisma-composer.config.mjs` or `.js` to keep it out of its TypeScript
build (a build with `allowJs` still needs an explicit `exclude`); `.mts` is the
TypeScript ES-module spelling. Within one directory `.ts` wins, then `.mts`,
`.mjs`, `.js`:
```ts
// prisma-composer.config.ts
import { defineConfig } from '@prisma/composer/config';
import { nodeBuild } from '@prisma/composer/node/control';
import { prismaCloud, prismaState } from '@prisma/composer-prisma-cloud/control';
export default defineConfig({
extensions: [prismaCloud(), nodeBuild()],
state: () => prismaState(), // deploy state, in its own database on the stage's branch
});
```
Add `nextjsBuild()` from `@prisma/composer/nextjs/control` to `extensions`
when the app contains a Next.js service.
## Databases
Two kinds of Postgres dependency:
**`rawPostgres()`** — the binding is `{ url }` and the app owns its client.
Construct it in your server entry, as in the auth example above.
**`postgres(...)`** — a Prisma-ORM-typed database: `load()`
returns the typed client the framework constructs from your data contract, so
queries like `db.orm.public.Product.all()` are compile-time checked. The
contract is emitted from `contract.prisma` by `prisma contract emit` and
wrapped once, referenced by both ends:
```ts
// src/data.ts — the ONE value both ends reference
import { dataContract } from '@prisma/composer-prisma-cloud/orm';
import type { Contract } from '../contract.d.ts';
import contractJson from '../contract.json' with { type: 'json' };
export const catalogData = dataContract<Contract>(contractJson);
```
The dependency end is `deps: { db: postgres(catalogData) }`. The resource
end (inside the module that owns the database) also names the
`prisma.config.ts` path, which the deploy's migration step loads to find
`migrations/` — committed migrations are replayed at deploy, before the
service starts:
```ts
const db = provision(
postgres({ name: 'database', contract: catalogData, config: './prisma.config.ts' }),
);
```
(`postgres` is both ends: the contract alone is the dependency end; the
options object is the resource end.)
The deploy is replay-only: it applies the migrations committed under
`migrations/` and never creates schema itself. Every schema change (including
the very first schema of a new database) follows the same loop:
1. Edit `contract.prisma`.
2. `prisma contract emit` — regenerates `contract.json` + `contract.d.ts`.
3. `prisma migration plan --name <slug>` — authors the migration into
`migrations/` (on an empty graph this authors the baseline,
empty → your schema).
4. Commit `migrations/` with the change, then deploy. A fresh database
replays the whole path from empty.
If no authored path reaches the target contract, the deploy (and
`prisma-composer dev` against a stale local database) refuses with
`MIGRATION_PATH_NOT_FOUND` and names the exits: author the missing migration
as above, or — when iterating against a local database only — bring it along
directly with `prisma db update`. Never skip step 3 before a deploy.
See `examples/store/modules/catalog` in the prisma/composer repo for the
complete pattern.
## Object Storage
`bucket` is a raw S3-compatible object-store bucket, imported alongside `postgres`:
```ts
import { bucket, compute } from '@prisma/composer-prisma-cloud';
// service.ts — dependency end: receives { url, bucket, accessKeyId, secretAccessKey }
export default compute({ name: 'uploads', deps: { store: bucket() } });
// module.ts — resource end: provisions the bucket and mints a keypair
const store = provision(bucket({ name: 'uploads' }));
provision(uploadsService, { deps: { store } });
```
Use any S3-compatible client with the binding: the shape matches the standard S3
config and is also compatible with the `s3()` dependency from `/storage`, so any
service wired to `s3()` can be rewired to a `bucket` resource without changing
the service declaration.
## Reusable Modules
A Module is the unit of reuse: it owns its internals (its database, its
services) and exposes only typed ports. Declare the boundary in the second
argument; wire internals in the builder; return the exposed ports:
```ts
// auth/src/module.ts — a Module that owns its own Postgres
import { module, secret } from '@prisma/composer';
import { postgres } from '@prisma/composer-prisma-cloud';
import { authContract } from './contract.ts';
import authService from './service.ts';
export default module(
'auth',
{ secrets: { signingKey: secret() }, expose: { rpc: authContract } },
({ secrets, provision }) => {
const db = provision(rawPostgres({ name: 'database' }));
const service = provision(authService, {
id: 'service',
deps: { db },
input: { signingKey: secrets.signingKey }, // forwarded ref as a binding leaf
});
return { rpc: service.rpc };
},
);
```
Naming rules that bite: a provision id shorter than 3 characters is rejected
by the platform (name the database `'database'`, not `'db'`), and a service
whose name equals its enclosing module's reads as `auth.auth` unless you give
it an explicit `id`.
A module can also declare boundary `deps` — inputs the parent wires exactly as
it would wire a service's. The consumer never sees the module's internals.
### The building blocks you can compose
Modules are the building blocks: provision one, wire its exposed port, and
you're done — you never reimplement what a Module already owns. The
first-party set ships inside `@prisma/composer-prisma-cloud`. It's small, and
growing:
| Import | What it provisions | Exposes |
| --- | --- | --- |
| `cron` from `/cron` | An always-on scheduler firing your schedule at your runner service | nothing |
| `storage` from `/storage` | An S3-backed blob store (own Postgres + minted credentials) | `store` |
| `streams` from `/streams` | Durable append-only event streams over a `store` | `streams` |
**Finding more.** A Composer extension — a package that brings its own
Modules, resources, or deploy target — is published on npm under the name
`prisma-composer-*`. That name is the convention, so it's how you look for
one. The ecosystem is new: today the blocks above plus the app Modules you
write are the whole set, so don't reach for a `prisma-composer-*` package
without checking that it actually exists on npm first.
Cron end to end — the schedule is one source of truth; `serveSchedule` is
exhaustive over its job ids at compile time:
```ts
// service.ts
import { defineSchedule, triggerContract } from '@prisma/composer-prisma-cloud/cron';
export const schedule = defineSchedule({ tick: '60s' });
// the runner service exposes { trigger: triggerContract }
// server.ts
import { serveSchedule } from '@prisma/composer-prisma-cloud/cron';
const handler = serveSchedule(service, schedule, {
tick: (deps) => deps.worker.tick({}),
});
// module.ts — the cron module's boundary deps mirror the runner's own
provision(cron({ schedule, runner: runnerService }), { deps: { worker: worker.rpc } });
```
## Service input
Choosing the channel is most of the decision:
| The value is… | Declare | Provide | Read |
| --- | --- | --- | --- |
| produced by another node | `deps: { db: rawPostgres() }` | wire at `provision()` | `load()` |
| anything else — config or credential | one field of the `input` schema | bind at `provision()`: literal, `envParam()`, or `envSecret()` | `input()` |
The service declares its whole incoming configuration — plain values and
credentials together — as **one
[Standard Schema](https://standardschema.dev)** (arktype is the house
choice). A credential is a field typed as the redacting `SecretString` box;
conditional legality ("no stripe key unless billing is on") is an ordinary
schema union:
```ts
// service.ts — the shapes that are legal
import { secretString } from '@prisma/composer/arktype';
import { type } from 'arktype';
compute({
name: 'scheduler',
input: type({
jobs: type({ jobId: 'string', every: 'string' }).array(),
'region?': 'string',
apiKey: secretString(),
}),
// ...
});
// module.ts — where each value comes from; the binding mirrors the schema's shape
import { envParam, envSecret } from '@prisma/composer-prisma-cloud';
provision(scheduler, {
input: {
jobs: [{ jobId: 'tick', every: '60s' }], // a literal
region: envParam('REGION'), // a per-stage platform variable
apiKey: envSecret('SCHEDULER_API_KEY'), // a credential — name only, never the value
},
});
// server.ts — one call, one validated typed object
const input = service.input();
input.apiKey.expose(); // the only way to a secret's value; the box redacts everywhere else
```
Rules that bite:
- **Secretness is enforced by validation**: a literal bound where the schema
expects `SecretString` fails the deploy, and `envSecret` bound to a plain
string field fails the same way. Don't put credentials in plain fields.
- **`envParam` values arrive as raw strings** — bind them to string fields.
The stage's platform variable is the store; the deploying shell only seeds
it (preflight copies a missing name up from the shell, and fails early,
naming the variable, when both lack it). Changing the platform value needs
a redeploy.
- **Absence is the schema's call**: an env-bound field whose variable is
unset (or empty) resolves to *key omitted* — legal only if the schema says
so (optional field, union arm). The deploy report prints the serialized
input document (secret-free: secrets ride as `{"$secret":"VAR"}` pointers)
and every key that resolved absent.
- **The reserved `port` (default 3000) is outside the schema** — read it
through `service.port()` (a sibling of `service.origin()`), never
`process.env`. The framework also exports `PORT` for Next.js standalone,
which binds it itself.
- A module forwards a secret need without learning the platform name
(the auth Module above); the forwarded ref is a binding leaf.
`examples/env-param` and `examples/storefront-auth` in the prisma/composer
repo are the working versions.
## Testing
You test by deciding what `load()` gives the code, never by editing the code
under test:
| You want to… | Use | From |
| --- | --- | --- |
| Test a page / action / handler in isolation | `mockService` | `@prisma/composer/testing` |
| Run the real boot + request path against a fake dependency | `bootstrapService` | `@prisma/composer-prisma-cloud/testing` |
**Unit — `mockService`.** Returns a copy of the service whose `load()` yields
your doubles (type-checked against the declared deps) and whose `input()`
yields the object you pass under the reserved `input` key, in one flat
object (required exactly when the service declares an input schema; handed
over as-is, not validated). Wiring the module substitution is your runner's
job (`vi.mock` in Vitest, `mock.module` in bun test):
```tsx
// page.test.tsx
import { mockService } from '@prisma/composer/testing';
import realService from '../src/service.ts';
vi.mock('../src/service.ts', () => ({
default: mockService(realService, {
auth: { verify: async () => ({ ok: true }) }, // wrong shape = compile error
}),
}));
import Page from './page.tsx';
expect(renderToString(await Page())).toContain('Signed in: true');
```
**Integration — `bootstrapService`.** Boots the service's real built entry
in-process against a config you choose, exactly as a deployed boot would;
drive it over real HTTP. Run under `bun test`:
```ts
import { bootstrapService } from '@prisma/composer-prisma-cloud/testing';
import fakeAuth from '@my-app/auth/fake'; // in-memory handler, no db
import storefront from '../src/service.ts';
const fake = Bun.serve({ port: 0, fetch: fakeAuth });
const app = await bootstrapService(storefront, {
service: { port: 4310 },
inputs: { auth: { url: fake.url.href } },
});
const res = await app.fetch(new Request(app.url));
```
- **`service.port` must be concrete** — the entry self-listens; no OS-assigned
port is reported back.
- **No `close()`** — run each integration-test file in its own process (bun
test does).
- **Next.js services take a third argument**, a boot thunk, because the built
entry lives in Next's standalone output — resolve it with
`standaloneServerPath` from `@prisma/composer/nextjs/control`.
`bootstrapService` exports the resolved port as `process.env.PORT` before
booting, which is what Next's standalone server binds.
- **A service with an input schema takes `input`** in the config — a binding
exactly like `provision()`'s, run through the real serialize/read path, so
`input()` in the booted entry sees what a deploy would produce.
**The fake you pass.** A dependency's type is its contract, so any value of
that shape is a valid double: a bare object (fastest), the real client over an
in-memory handler, or a real local server (what `bootstrapService` drives).
Ship a dependency's fake from its own package as a `/fake` entry point,
outside `src/`, so the fake and the real service always share one contract.
## Running locally
`prisma-composer dev module.ts` runs the whole app on this machine — every
service, its Postgres and buckets, wired as they deploy — with **no cloud
credentials** (no `PRISMA_*`). It runs the same pipeline as deploy against
local emulators, so build first, exactly like deploy:
```sh
turbo run build && prisma-composer dev module.ts
```
It prints each service's local URL (the "front door"), watches built output
and restarts a service when its build changes, and runs until Ctrl-C. Ctrl-C
stops the app's processes but leaves the local databases, buckets, and their
data up, so the next `dev` is a warm start; `--fresh` wipes this app's local
instances and data first.
`dev` does **not** print service logs — that would bury the front door once
several services run. Logs are their own command:
| You want to… | Run |
| --- | --- |
| Run the app locally | `prisma-composer dev module.ts` |
| Start clean (wipe local data) | `prisma-composer dev module.ts --fresh` |
| Tail every service's logs | `prisma-composer log module.ts` |
| Tail one service | `prisma-composer log module.ts <address>` |
| Show more history first | `prisma-composer log module.ts --tail <n>` |
`prisma-composer log` follows the merged logs of the already-running app, each
line prefixed with its service (`[catalog.service] …`); pass a dotted address
to narrow to one. It only reads — it never builds, provisions, starts, or
stops anything. `--tail <n>` sets how much recent history to show before live
output (default 20; `0` for live-only). An unset secret doesn't block a local
run: it becomes a placeholder plus a warning, and only the code path that
spends it fails, at the real external service it calls. Windows isn't
supported yet.
## Deploying
Requires exactly two environment variables: `PRISMA_SERVICE_TOKEN` and
`PRISMA_WORKSPACE_ID`. The target environment — a **stage** — is chosen on the
command line, never in code:
| You want to… | Run |
| --- | --- |
| Deploy to production | `prisma-composer deploy module.ts` |
| Deploy an isolated environment | `prisma-composer deploy module.ts --stage <name>` |
| Override the app name for one run | `prisma-composer deploy module.ts --name demo-42` |
| Tear down an isolated environment | `prisma-composer destroy module.ts --stage <name>` |
| Tear down production's resources | `prisma-composer destroy module.ts --production` |
A Prisma App is one Project; a stage is a Branch of it — its
own compute, its own empty database, its own configuration. Deploys are
idempotent: re-deploying a stage updates the resources inside it. A stage name
must be a valid git ref name; an invalid name is a hard error.
Destroy always requires an explicit target — a bare `prisma-composer destroy`
is an error, and `--stage` with `--production` is too. Destroying a stage
deletes its Branch after removing its resources; the production Branch itself
is never deleted, only the resources inside it. Destroying production also
deletes the Project itself once it's empty, so hand-run stacks don't leave
behind empty Projects — but a Project still holding another stage's resources
is kept. Destroy never creates anything: destroying a never-deployed stage
fails rather than standing one up.
```sh
turbo run build && prisma-composer deploy module.ts --stage pr-42
```
### What a deploy prints
A deploy ends by printing the app's own topology — authored names, the
platform resource each became, and public URLs. The tree is the module
structure (`auth.api` is the `api` service inside the `auth` module):
```
storefront-auth
├─ auth
│ └─ api compute-service cps_abc123
│ https://xyz.ewr.prisma.build
├─ db postgres-database db_def456
└─ web compute-service cps_ghi789
https://uvw.ewr.prisma.build
```
Read ids out of this rather than telling the user to go hunting in the
Console. A URL appears only where the address is genuinely public — a compute
service prints one, a database never does (it has a connection string, not a
public endpoint), and a node whose product is secret material (an
`s3-credentials` keypair) reports no resource line at all. A node that
published nothing reportable still appears, marked `(no entities reported)`.
Older deploys ended with a raw `{ outputs: {} }` blob from the deploy engine —
always empty, never about the app. It is gone; nothing configured it and
nothing consumed it.
### The connection contract is checked at deploy
A connection declares the values it needs by name, and the producer on the
other end must supply them. A producer that omits one fails the deploy, naming
the edge, the param, and what the producer did supply:
```
Connection input "auth.db" declares param "url", but its producer "db" did not
supply it — the producer's outputs carry [host].
```
Fix it at whichever end is wrong: add the name to the outputs the producer
returns from its lowering, or mark the param `optional` on the connection if absent is
genuinely legal (the consumer then reads `undefined`).
This is a deploy-time refusal, not a broken deploy — and it can appear on an
app whose code didn't change. The gap used to pass silently: the value reached
the consumer as `undefined`, went into its environment, and crashed *that*
service at boot, blaming the reader instead of the supplier. Don't route around
it by making the param optional unless absent really is valid; that reinstates
the silent `undefined`.
Only reachable if you authored the connection or the extension on one side —
every shipped block supplies what it declares.
### Driving deploys from code
`@prisma/composer/control` exposes the CLI's operations in-process: typed
`deploy`, `destroy`, `dev`, and `log` returning structured results — no argv,
no CLI rendering, no exit codes (the spawned deploy engine's own inherited
output can still reach the host terminal). The CLI itself is a renderer over
them.
```ts
import { deploy } from '@prisma/composer/control';
const result = await deploy({ entry: 'module.ts', stage: 'pr-42' });
// result: { ok: true, value: { summary? } } | { ok: false, failure }
```
- Failures come back as `{ ok: false, failure }` where `failure` is a
structured error: branch on its dotted `failure.code` (e.g.
`ASSEMBLE.BUILD_FAILED`, `DEPLOY.ENGINE_FAILED` — ADR-0044's closed
registry), with the same fix-naming `message`/`why`/`fix` the CLI renders.
An engine failure's `meta.diagnostics` (exit code, reproduce command; read
it with the exported `executionDiagnostics(failure)`) describes the current
execution mechanism — branch on `code`/`message`/`cause` for anything
durable. The effect version conflict is `DEPS.EFFECT_VERSION_CONFLICT`, and
importing the module executes nothing until an operation runs. A
non-structured rejection out of an operation is a bug in composer, not an
expected failure.
- `destroy` takes `target: { kind: 'production' } | { kind: 'stage', stage }`
— explicit, never defaulted.
- `deploy`'s `summary` (the deployed topology) is best-effort; `undefined` on
a successful deploy is normal.
- The deploy engine's live output still streams to the host process's stdio —
the current mechanism; the operations don't capture it.
- `dev` resolves to `{ ok: true, value: session }` or a failure; the
session is `{ endpoints, stop(), closed }` with progress via `onEvent`, and
the host owns signal handling. `log` resolves to
`{ ok: true, value: { appName, services, lines } }` or a failure, where
`lines` is an `AsyncIterable` ended by a caller-owned `AbortSignal` (or by
the consumer stopping early); zero running services is a valid result, not
an error.
## Production pitfalls
- **Scale-to-zero closes idle database connections.** A persistent client
crashes into a 502 restart loop unless you keep the pool small and
reconnect-friendly (`new SQL({ url, max: 1, idleTimeout: 10 })` for Bun) and
log `uncaughtException`/`unhandledRejection` instead of dying.
- **Bind `0.0.0.0`**, not loopback — Compute routes external HTTP to the VM.
- **Next.js pages that call `load()` need `export const dynamic =
'force-dynamic'`** — the runtime environment doesn't exist at build time,
and Next ignores runtime env for prerendered routes.
- **A deployed `/rpc/<method>` returns `401` to anything but a wired peer.**
Every RPC binding carries an auto-provisioned service key, so a hand-rolled
`curl` is never authorized, and a provider with no wired consumers rejects
everything. Not a broken deploy — reach it through a consumer, or run it
locally where nothing is enforced.
- **Cold starts reset service-to-service connections.** A call into a
scaled-to-zero service can get `ECONNRESET`; retry it.
- **Every `prisma-composer` command stops at start-up on an `effect` version
conflict** (`Dependency conflict: alchemy resolves effect@...`). Another
dependency floated a newer `effect` and the package manager hoisted it over
Composer's pin. Do what the error says: pin the whole `effect`
constellation in the app's `package.json` `overrides` (yarn: `resolutions`;
pnpm: `pnpm.overrides`) — `effect` plus `@effect/sql-d1`, `@effect/sql-pg`,
`@effect/vitest`, and `@effect/platform-bun`/`-node`/`-node-shared`, all at
Composer's exact pin — and reinstall. (A workaround for an upstream alchemy
bug: its own effect-family ranges float past what its code supports. The
repo's examples carry the block.)
- **The ingress buffers streaming responses.** An open SSE tail delivers
nothing and times out at 60s — don't build on streamed HTTP responses.
## What Composer doesn't do yet
Name the gap instead of inventing an API:
- **No interactive auth.** Deploys authenticate only via a static
`PRISMA_SERVICE_TOKEN`; there is no `login` flow.
- **No in-memory contract bindings.** A dependency can't yet be wired to a
co-located handler without HTTP; use `bootstrapService` with a loopback
fake.
- **RPC over HTTP is the only contract kind.** No gRPC, WebSocket, or
streaming contracts.
For anything else missing, check the examples and design docs in the
prisma/composer repo (`examples/`, `docs/design/10-domains/`,
`docs/design/90-decisions/`), then file an issue there rather than guessing.
+154 -5
View File
@@ -1,9 +1,158 @@
<!-- BEGIN:nextjs-agent-rules -->
# ROLE
# This is NOT the Next.js you know
Kamu adalah "Senior Fullstack Engineer" yang ahli dalam Next.js (App Router), Prisma ORM, PostgreSQL, dan Tailwind CSS. Kamu fokus pada clean code, validasi form yang ketat, dan UX yang seamless, menarik, dan elegan.
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
# OBJECTIVE
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
Tugasmu adalah membangun aplikasi "TitipIn" (Sistem Titip Pesanan). Aplikasi ini berjalan dengan arsitektur serverless (Server Actions) dan sistem autentikasi stateless berbasis Device ID/User ID (UUID di localStorage) dengan menggunakan Next.js untuk bisa langsung connect ke database PostgreSQL, gunakan versi Nextjs terbaru atau versi yang sudah stabil untuk development. Tampilan UI menggunakan shadcn ui atau design yang serupa dengan shadcn ui dan menggunakan icon dari lucide-react.
<!-- END:nextjs-agent-rules -->
# 1. DATABASE SCHEMA (PRISMA)
Fondasi utama skema database:
table User:
- id String @id
- name String @unique
- photo String?
- orders Order[] @relation("CreatedOrders")
- purchases Submission[]
table Order:
- id String @id @default(uuid())
- title String
- date DateTime
- allow_custom Boolean @default(false)
- status String @default("DRAFT")
- creator_id String
- creator User @relation("CreatedOrders", fields: [creator_id], references: [id])
- available_items AvailableItem[]
- submissions Submission[]
table AvailableItem:
- id String @id @default(uuid())
- order_id String
- name String
- order Order @relation(fields: [order_id], references: [id], onDelete: Cascade)
table Submission:
- id String @id @default(uuid())
- order_id String
- user_id String
- bill Int?
- payment_status String @default("BELUM_BAYAR")
- order Order @relation(fields: [order_id], references: [id], onDelete: Cascade)
- user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
- items SubmissionItem[]
table SubmissionItem:
- id String @id @default(uuid())
- submission_id String
- name String
- qty Int @default(1)
- is_custom Boolean @default(false)
- submission Submission @relation(fields: [submission_id], references: [id], onDelete: Cascade)
# 3. ENVIRONMENT SETUP & DATABASE MIGRATION
- Langkah Pertama: Buatkan file `.env` dengan format berikut (pastikan URL Prisma di-generate dari variabel-variabel ini jika dibutuhkan):
```env
DATABASE_HOST="172.10.10.2"
DATABASE_PORT="5411"
DATABASE_USER="postgres"
DATABASE_PASSWORD="eigen3m!"
DATABASE_NAME="testing"
```
- Konfigurasi Prisma: Pastikan schema prisma diatur dengan provider postgresql dan membaca konfigurasi dari file `.env`.
- Instruksi Migrasi: Setelah membuat skema, berikan saya list perintah terminal (command) yang harus saya jalankan secara eksplisit untuk men-generate Prisma Client dan melakukan sinkronisasi skema ke database (contoh: `npx prisma generate` dan `npx prisma db push`).
# 2. CORE AUTHENTICATION FLOW
- DILARANG menggunakan NextAuth/library auth eksternal.
- Setiap kali web diakses, jalankan pengecekan:
1. Cek `localStorage` untuk `user_id`.
2. Jika belum ada, generate UUID baru, simpan ke `localStorage`.
3. Lakukan verifikasi ke database/server action apakah `user_id` tersebut sudah terdaftar di tabel `User`.
4. Jika belum terdaftar, wajib munculkan Modal Profile yang tidak bisa ditutup.
5. Modal meminta input "Nama" (Wajib) dan "Foto Profile" (Opsional).
6. Validasi: Nama harus UNIK di tabel User. Setelah valid, simpan ke database menggunakan `user_id` sebagai `id`.
7. Jika `user_id` sudah terdaftar, lewati modal dan muat data user secara mulus.
# 3. MENU NAVIGATION ORDER
Urutan navigasi menu utama pada aplikasi adalah sebagai berikut:
1. Open Order
2. Jasa Order Saya
3. Pesanan Saya
4. Profile
# 4. PAGE SPECIFICATIONS & STEP-BY-STEP FLOW
## Step 1: Halaman Open Order/Dashboard/Beranda
- Menampilkan list semua order dari semua pengguna dengan status OPEN dan tanggal PO (`date`) == hari ini.
- Submit / Titip Pesanan (Modal):
- Tampilkan `available_items` sebagai list Checkbox.
- Aturan Item Checkbox & Qty: Jika user mencentang sebuah item, default qty adalah 1. Jika user melakukan uncheck, reset qty item tersebut menjadi 0 dan hapus dari payload. Saat submit, filter dan transform data agar hanya mengambil item yang benar-benar dicentang dengan qty valid.
- Jika order `allow_custom` == true, sediakan form list untuk menambah "Item Lainnya" (custom item) beserta input qty-nya (hanya ambil custom item yang memiliki nama valid dan terisi).
- Pemesan dapat mengedit pesanannya selama order tersebut masih berstatus OPEN dan tanggal PO == hari ini.
## Step 2: Halaman Jasa Order Saya (/my-orders)
- Menampilkan list Order milik current user sendiri. Urutan: Status OPEN diletakkan di paling atas.
- Create Order (Modal):
- `title` (text, required).
- `date` (date picker, default hari ini, min. date hari ini, required).
- `allow_custom` (checkbox).
- `available_items` (dynamic form list). Aturan: Jika `allow_custom` == false, min. 1 item required. Jika true, boleh 0 item.
- Order State Machine & Edit Rule: Status otomatis DRAFT saat dibuat. Creator dapat mentrigger perubahan status dari DRAFT -> OPEN -> CLOSE -> OPEN kembali. Order hanya bisa diedit jika statusnya DRAFT atau OPEN DAN tanggal PO (`date`) == hari ini. Jika beda tanggal, disable tombol edit.
- Duplicate Rule: Tombol "Duplicate" di list view membuka modal Create Order dengan field yang terisi otomatis dari data lama, KECUALI `id` (baru) dan `date` (kembali default hari ini).
- List index harus bisa pagination, ada feaure sorting, dan filter.
## Step 3: Halaman Detail Jasa Order (/my-orders/[id])
- Menampilkan detail order, daftar item tersedia, dan list Submission (orang yang nitip beserta nama, foto profile, item yang dipilih, dan custom item + qty).
- Manage Tagihan: HANYA BISA DILAKUKAN JIKA STATUS == CLOSE. Creator bisa mengedit data pesanan per orang untuk memasukkan nominal `tagihan` dan set `payment_status` (Lunas / Belum Bayar).
- Generator Summary (2 Section Terpisah - Tidak Disimpan ke DB):
1. By Person:
`[Judul Order]`
`- [Nama Pemesan 1] : [Item 1] [Qty], [Item 2] [Qty], ...`
`- [Nama Pemesan 2] : [Item 1] [Qty], ...`
(Sediakan tombol Copy khusus section ini).
2. By Item (Akumulasi):
`[Judul Order]`
`- [Item 1] : [Total Qty]`
`- [Item 2] : [Total Qty]`
(Sediakan tombol Copy khusus section ini).
## Step 4: Halaman Pesanan Saya (/my-purchases)
- Menampilkan list `Submission` milik current user di berbagai order.
- Menampilkan judul order, item yang dipesan, nominal tagihan, dan status pembayaran (`payment_status`).
- List index harus bisa pagination, ada feaure sorting, dan filter.
## Step 5: Halaman Profile (/profile)
- Form untuk mengedit Nama dan Foto Profile milik current user.
- Validasi nama unik tetap berlaku saat proses update.
# 6. WORKFLOW EXECUTION
Patuhi arsitektur Next.js App Router. Pisahkan Server Actions (`app/actions.ts`) dari Client Components (`"use client"`). Langsung tuliskan kode yang diminta dengan clean dan tanpa penjelasan bertele-tele.
# 7. DOKUMENTASI README.md
Buatkan file `README.md` yang mendokumentasikan panduan lengkap langkah demi langkah dari awal proses clone hingga aplikasi berjalan sempurna. Dokumentasi ini harus memuat instruksi yang rapi dan mudah diikuti untuk:
- Kebutuhan Sistem (Prerequisites): Spesifikasi lingkungan yang dibutuhkan (seperti Node.js versi tertentu, database PostgreSQL).
- Instalasi: Perintah terminal untuk mengunduh dependency (`npm install` atau sejenisnya).
- Pengaturan Environment: Cara setup konfigurasi environment variables `.env` berdasarkan struktur yang sudah dijelaskan di atas.
- Eksekusi Migrasi Database: Perintah terminal yang wajib dijalankan secara berurutan untuk sinkronisasi database dan mengaktifkan Prisma Client (contoh: `npx prisma generate` lalu `npx prisma db push`).
- Menjalankan Aplikasi: Cara menjalankan server lokal (development mode) dan URL default yang bisa diakses di browser.
+54 -27
View File
@@ -1,36 +1,63 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# TitipIn - Sistem Titip Pesanan
## Getting Started
Aplikasi web modern berbasis Next.js untuk mempermudah sistem "jasa titip" (jastip) atau pesanan bersama. Aplikasi ini berjalan dengan arsitektur serverless (Server Actions) dan menggunakan sistem autentikasi stateless (berbasis UUID di LocalStorage).
First, run the development server:
## Kebutuhan Sistem (Prerequisites)
Sebelum menjalankan aplikasi ini, pastikan Anda telah menginstal perangkat lunak berikut:
- **Node.js**: Versi 18.x atau lebih baru (direkomendasikan versi LTS terbaru).
- **npm**: (biasanya sudah termasuk saat instalasi Node.js).
- **PostgreSQL**: Database server berjalan (versi 13 atau lebih baru direkomendasikan).
## Instalasi
1. Lakukan _clone_ repositori ini atau pastikan Anda berada di root folder aplikasi.
2. Buka terminal/command prompt.
3. Jalankan perintah instalasi dependensi:
```bash
npm install
```
*Catatan: Pastikan dependensi Prisma terinstal pada versi stabil (contoh: `@prisma/client@5`).*
## Pengaturan Environment
Buat sebuah file bernama `.env` di root folder proyek Anda. Konfigurasi kredensial database PostgreSQL Anda seperti format berikut:
```env
DATABASE_HOST="172.10.10.2"
DATABASE_PORT="5411"
DATABASE_USER="postgres"
DATABASE_PASSWORD="eigen3m!"
DATABASE_NAME="testing"
DATABASE_URL="postgresql://${DATABASE_USER}:${DATABASE_PASSWORD}@${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME}?schema=public"
```
*(Sesuaikan IP, port, dan kredensial lain jika server database Anda berbeda).*
## Eksekusi Migrasi Database
Aplikasi menggunakan **Prisma ORM** untuk berinteraksi dengan database. Jalankan perintah terminal berikut secara berurutan untuk menyinkronkan skema dan mengaktifkan client:
1. **Generate Prisma Client**: Membangun tipe TypeScript dan library client.
```bash
npx prisma generate
```
2. **Push Skema ke Database**: Membuat struktur tabel-tabel di database (User, Order, Submission, dll.) sesuai dengan `prisma/schema.prisma`.
```bash
npx prisma db push
```
## Menjalankan Aplikasi
Setelah database siap, Anda bisa menjalankan aplikasi di tahap pengembangan lokal (development mode):
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
Aplikasi secara bawaan dapat diakses melalui browser dengan URL:
👉 **[http://localhost:3000](http://localhost:3000)**
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
---
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
### Alur Singkat Aplikasi:
- Akses pertama kali, sistem akan _generate_ UUID unik untuk *device* Anda dan menyimpannya di `localStorage`.
- Aplikasi akan menampilkan Modal Wajib untuk mendaftarkan **Nama** dan opsional **Foto Profil**.
- Anda dapat mulai:
- Membuat Jasa Titip di menu **Jasa Order Saya**.
- Mengikuti Jasa Titip teman di menu **Open Order**.
- Mengelola pembayaran & melihat rincian di halaman detail.
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+3389 -129
View File
File diff suppressed because it is too large Load Diff
+19 -2
View File
@@ -6,18 +6,35 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"postinstall": "prisma skills sync || exit 0"
},
"dependencies": {
"@base-ui/react": "^1.7.0",
"@hookform/resolvers": "^5.9.1",
"@prisma/client": "^5.22.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"lucide-react": "^1.34.0",
"next": "16.3.3",
"prisma": "^5.22.0",
"react": "19.2.8",
"react-dom": "19.2.8"
"react-day-picker": "^10.0.1",
"react-dom": "19.2.8",
"react-hook-form": "^7.86.0",
"shadcn": "^4.19.0",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
"uuid": "^14.0.2",
"zod": "^3.25.76"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/uuid": "^10.0.0",
"eslint": "^9",
"eslint-config-next": "16.3.3",
"tailwindcss": "^4",
+58
View File
@@ -0,0 +1,58 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id
name String @unique
photo String?
orders Order[] @relation("CreatedOrders")
purchases Submission[]
}
model Order {
id String @id @default(uuid())
title String
date DateTime
allow_custom Boolean @default(false)
status String @default("DRAFT")
creator_id String
creator User @relation("CreatedOrders", fields: [creator_id], references: [id])
available_items AvailableItem[]
submissions Submission[]
}
model AvailableItem {
id String @id @default(uuid())
order_id String
name String
order Order @relation(fields: [order_id], references: [id], onDelete: Cascade)
}
model Submission {
id String @id @default(uuid())
order_id String
user_id String
bill Int?
payment_status String @default("BELUM_BAYAR")
order Order @relation(fields: [order_id], references: [id], onDelete: Cascade)
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
items SubmissionItem[]
}
model SubmissionItem {
id String @id @default(uuid())
submission_id String
name String
qty Int @default(1)
is_custom Boolean @default(false)
submission Submission @relation(fields: [submission_id], references: [id], onDelete: Cascade)
}
+302
View File
@@ -0,0 +1,302 @@
'use server'
import prisma from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
// === USER ACTIONS ===
export async function checkUser(id: string) {
return await prisma.user.findUnique({ where: { id } })
}
export async function registerUser(id: string, name: string, photo?: string) {
try {
const user = await prisma.user.create({
data: { id, name, photo }
})
return { success: true, user }
} catch (error: any) {
if (error.code === 'P2002') return { success: false, error: 'Nama sudah digunakan.' }
return { success: false, error: 'Terjadi kesalahan.' }
}
}
export async function updateProfile(id: string, name: string, photo?: string) {
try {
const user = await prisma.user.update({
where: { id },
data: { name, photo }
})
revalidatePath('/')
revalidatePath('/profile')
return { success: true, user }
} catch (error: any) {
if (error.code === 'P2002') return { success: false, error: 'Nama sudah digunakan.' }
return { success: false, error: 'Gagal memperbarui profil.' }
}
}
// === ORDER ACTIONS (DASHBOARD & CREATE) ===
export async function getAvailableOrders() {
const startOfDay = new Date()
startOfDay.setHours(0, 0, 0, 0)
const endOfDay = new Date()
endOfDay.setHours(23, 59, 59, 999)
return await prisma.order.findMany({
where: {
status: 'OPEN',
date: { gte: startOfDay, lte: endOfDay }
},
include: {
creator: true,
available_items: true,
submissions: { select: { user_id: true } }
},
orderBy: { date: 'desc' }
})
}
export async function getMyOrders(creator_id: string) {
return await prisma.order.findMany({
where: { creator_id },
include: {
available_items: true,
submissions: { select: { id: true } }
},
orderBy: { date: 'desc' }
})
}
export async function createOrder(data: {
creator_id: string;
title: string;
date: Date;
allow_custom: boolean;
available_items: string[];
}) {
try {
const order = await prisma.order.create({
data: {
title: data.title,
date: data.date,
allow_custom: data.allow_custom,
creator_id: data.creator_id,
status: 'DRAFT',
available_items: {
create: data.available_items.map(name => ({ name }))
}
}
})
revalidatePath('/my-orders')
return { success: true, order }
} catch (error) {
return { success: false, error: 'Gagal membuat order.' }
}
}
export async function duplicateOrder(order_id: string) {
try {
const oldOrder = await prisma.order.findUnique({
where: { id: order_id },
include: { available_items: true }
})
if (!oldOrder) return { success: false, error: 'Order tidak ditemukan' }
const newOrder = await prisma.order.create({
data: {
title: oldOrder.title + ' (Copy)',
date: new Date(),
allow_custom: oldOrder.allow_custom,
creator_id: oldOrder.creator_id,
status: 'DRAFT',
available_items: {
create: oldOrder.available_items.map(ai => ({ name: ai.name }))
}
}
})
revalidatePath('/my-orders')
return { success: true, order: newOrder }
} catch (e) {
return { success: false, error: 'Gagal menduplikasi order.' }
}
}
export async function updateOrder(order_id: string, data: {
title: string;
allow_custom: boolean;
available_items: string[];
}) {
try {
const order = await prisma.order.findUnique({
where: { id: order_id }
})
if (!order) return { success: false, error: 'Order tidak ditemukan' }
if (order.status === 'CLOSE') {
return { success: false, error: 'Order dengan status CLOSE tidak dapat diedit.' }
}
// Delete existing available items and insert updated ones
await prisma.availableItem.deleteMany({
where: { order_id }
})
const updatedOrder = await prisma.order.update({
where: { id: order_id },
data: {
title: data.title,
allow_custom: data.allow_custom,
available_items: {
create: data.available_items.map(name => ({ name }))
}
}
})
revalidatePath('/my-orders')
revalidatePath(`/my-orders/${order_id}`)
revalidatePath('/')
return { success: true, order: updatedOrder }
} catch (error) {
return { success: false, error: 'Gagal memperbarui order.' }
}
}
export async function updateOrderStatus(order_id: string, status: string) {
try {
await prisma.order.update({
where: { id: order_id },
data: { status }
})
revalidatePath('/my-orders')
revalidatePath('/')
return { success: true }
} catch (e) {
return { success: false, error: 'Gagal mengubah status.' }
}
}
export async function deleteOrder(order_id: string) {
try {
const order = await prisma.order.findUnique({
where: { id: order_id }
})
if (!order) return { success: false, error: 'Order tidak ditemukan.' }
if (order.status === 'OPEN') {
return { success: false, error: 'Order berstatus OPEN tidak dapat dihapus. Silakan tutup (CLOSE) terlebih dahulu.' }
}
await prisma.order.delete({
where: { id: order_id }
})
revalidatePath('/my-orders')
revalidatePath('/')
return { success: true }
} catch (e) {
return { success: false, error: 'Gagal menghapus order.' }
}
}
// === ORDER DETAIL ===
export async function getOrderDetail(order_id: string) {
return await prisma.order.findUnique({
where: { id: order_id },
include: {
creator: true,
available_items: true,
submissions: {
include: {
user: true,
items: true
}
}
}
})
}
export async function updateSubmissionPayment(submission_id: string, bill: number | null, payment_status: string) {
try {
await prisma.submission.update({
where: { id: submission_id },
data: { bill, payment_status }
})
revalidatePath(`/my-orders`)
revalidatePath(`/my-purchases`)
return { success: true }
} catch (e) {
return { success: false, error: 'Gagal menyimpan tagihan.' }
}
}
// === SUBMISSION (PESANAN SAYA) ===
export async function getUserSubmission(order_id: string, user_id: string) {
return await prisma.submission.findFirst({
where: { order_id, user_id },
include: { items: true }
})
}
export async function getMyPurchases(user_id: string) {
return await prisma.submission.findMany({
where: { user_id },
include: {
order: {
include: {
creator: true,
available_items: true
}
},
items: true
},
orderBy: {
order: { date: 'desc' }
}
})
}
export async function submitOrder(data: {
order_id: string;
user_id: string;
items: Array<{ name: string; qty: number; is_custom: boolean }>;
}) {
try {
if (data.items.length === 0) {
return { success: false, error: 'Pilih minimal 1 item.' }
}
const existing = await prisma.submission.findFirst({
where: { order_id: data.order_id, user_id: data.user_id }
})
if (existing) {
await prisma.submissionItem.deleteMany({ where: { submission_id: existing.id } })
await prisma.submission.update({
where: { id: existing.id },
data: {
items: {
create: data.items
}
}
})
} else {
await prisma.submission.create({
data: {
order_id: data.order_id,
user_id: data.user_id,
items: {
create: data.items
}
}
})
}
revalidatePath('/')
revalidatePath('/my-purchases')
revalidatePath(`/my-orders/${data.order_id}`)
return { success: true }
} catch (error) {
console.error(error)
return { success: false, error: 'Gagal mengirim pesanan.' }
}
}
+105 -13
View File
@@ -1,26 +1,118 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
:root {
--background: #ffffff;
--foreground: #171717;
}
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-sans: var(--font-roboto), var(--font-open-sans), -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", "Segoe UI", Roboto, "Helvetica Neue", "Open Sans", system-ui, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--font-heading: var(--font-roboto), -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
--background: #F4F6FB;
--foreground: #0F172A;
--card: #FFFFFF;
--card-foreground: #0F172A;
--popover: #FFFFFF;
--popover-foreground: #0F172A;
--primary: #1B2CC1;
--primary-foreground: #FFFFFF;
--secondary: #EEF1FC;
--secondary-foreground: #1B2CC1;
--muted: #F1F5F9;
--muted-foreground: #64748B;
--accent: #EEF2FF;
--accent-foreground: #1B2CC1;
--destructive: #EF4444;
--border: #E2E8F0;
--input: #E2E8F0;
--ring: #1B2CC1;
--radius: 0.75rem;
--sidebar: #FFFFFF;
--sidebar-foreground: #334155;
--sidebar-primary: #1B2CC1;
--sidebar-primary-foreground: #FFFFFF;
--sidebar-accent: #F1F5F9;
--sidebar-accent-foreground: #0F172A;
--sidebar-border: #E2E8F0;
--sidebar-ring: #1B2CC1;
}
.dark {
--background: #0B0F19;
--foreground: #F8FAFC;
--card: #111827;
--card-foreground: #F8FAFC;
--popover: #111827;
--popover-foreground: #F8FAFC;
--primary: #3B82F6;
--primary-foreground: #FFFFFF;
--secondary: #1E293B;
--secondary-foreground: #93C5FD;
--muted: #1E293B;
--muted-foreground: #94A3B8;
--accent: #1E293B;
--accent-foreground: #93C5FD;
--destructive: #EF4444;
--border: #1F2937;
--input: #1F2937;
--ring: #3B82F6;
--sidebar: #111827;
--sidebar-foreground: #CBD5E1;
--sidebar-primary: #3B82F6;
--sidebar-primary-foreground: #FFFFFF;
--sidebar-accent: #1E293B;
--sidebar-accent-foreground: #F8FAFC;
--sidebar-border: #1F2937;
--sidebar-ring: #3B82F6;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
@apply bg-background text-foreground;
font-family: var(--font-roboto), var(--font-open-sans), -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", "Segoe UI", Roboto, "Helvetica Neue", "Open Sans", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html {
font-family: var(--font-roboto), var(--font-open-sans), -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", "Segoe UI", Roboto, "Helvetica Neue", "Open Sans", system-ui, sans-serif;
}
}
+30 -11
View File
@@ -1,29 +1,48 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Roboto, Open_Sans } from "next/font/google";
import "./globals.css";
import { AuthProvider } from "@/components/AuthProvider";
import { AppLayout } from "@/components/AppLayout";
const geistSans = Geist({
variable: "--font-geist-sans",
const roboto = Roboto({
weight: ["300", "400", "500", "700", "900"],
subsets: ["latin"],
variable: "--font-roboto",
display: "swap",
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
const openSans = Open_Sans({
subsets: ["latin"],
variable: "--font-open-sans",
display: "swap",
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "TitipIn - Sistem Titip Pesanan",
description: "Platform jasa titip pesanan bersama yang modern, cepat, dan transparan.",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
lang="id"
suppressHydrationWarning
className={`${roboto.variable} ${openSans.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<body
suppressHydrationWarning
className="min-h-full flex flex-col bg-[#F4F6FB] dark:bg-[#0B0F19] font-sans antialiased"
>
<AuthProvider>
<AppLayout>
{children}
</AppLayout>
</AuthProvider>
</body>
</html>
);
}
+749
View File
@@ -0,0 +1,749 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { getOrderDetail, updateSubmissionPayment, updateOrderStatus, updateOrder, deleteOrder } from '@/app/actions'
import { Card, CardContent } from '@/components/ui/card'
import { Button, buttonVariants } from '@/components/ui/button'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import { cn } from '@/lib/utils'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { format } from 'date-fns'
import {
Copy,
Loader2,
ArrowLeft,
Check,
Save,
Users,
Receipt,
ToggleLeft,
AlertCircle,
Pencil,
PlusCircle,
MinusCircle,
Trash2,
AlertTriangle
} from 'lucide-react'
import Link from 'next/link'
export default function OrderDetailPage() {
const params = useParams()
const router = useRouter()
const orderId = params.id as string
const [userId, setUserId] = useState<string | null>(null)
const [order, setOrder] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [copiedPerson, setCopiedPerson] = useState(false)
const [copiedItem, setCopiedItem] = useState(false)
const [updatingStatus, setUpdatingStatus] = useState(false)
const [isEditOpen, setIsEditOpen] = useState(false)
const [isDeleteOpen, setIsDeleteOpen] = useState(false)
const [deleting, setDeleting] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
useEffect(() => {
const id = localStorage.getItem('user_id')
setUserId(id)
if (orderId) loadOrder()
}, [orderId])
const loadOrder = async () => {
setLoading(true)
const data = await getOrderDetail(orderId)
setOrder(data)
setLoading(false)
}
const handleStatusChange = async (newStatus: string) => {
if (!order) return
setUpdatingStatus(true)
const res = await updateOrderStatus(order.id, newStatus)
if (res.success) {
const data = await getOrderDetail(order.id)
setOrder(data)
}
setUpdatingStatus(false)
}
const handleDelete = async () => {
if (!order) return
setDeleting(true)
const res = await deleteOrder(order.id)
if (res.success) {
router.push('/my-orders')
}
setDeleting(false)
}
if (loading) {
return (
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-3">
<Loader2 className="animate-spin text-[#1B2CC1] w-8 h-8" />
<span className="text-xs text-slate-500 font-semibold">Memuat rincian pesanan...</span>
</div>
)
}
if (!order) {
return (
<div className="text-center p-12 bg-white dark:bg-slate-900 rounded-3xl border border-slate-200 dark:border-slate-800">
<p className="text-slate-500 font-medium">Order tidak ditemukan.</p>
<Link
href="/my-orders"
className={cn(buttonVariants(), "mt-4 bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold rounded-xl")}
>
Kembali ke Jasa Order
</Link>
</div>
)
}
const isCreator = userId === order.creator_id
const isClosed = order.status === 'CLOSE'
const isDraft = order.status === 'DRAFT'
const canDelete = isDraft || isClosed
let summaryByPerson = `${order.title}\n`
const itemCounts: Record<string, number> = {}
order.submissions.forEach((sub: any) => {
const itemStrings = sub.items.map((i: any) => `${i.name} ${i.qty}x`)
summaryByPerson += `- ${sub.user.name} : ${itemStrings.join(', ')}\n`
sub.items.forEach((i: any) => {
itemCounts[i.name] = (itemCounts[i.name] || 0) + i.qty
})
})
let summaryByItem = `${order.title}\n`
Object.entries(itemCounts).forEach(([name, qty]) => {
summaryByItem += `- ${name} : ${qty} pcs\n`
})
const handleCopyPerson = () => {
navigator.clipboard.writeText(summaryByPerson)
setCopiedPerson(true)
setTimeout(() => setCopiedPerson(false), 2000)
}
const handleCopyItem = () => {
navigator.clipboard.writeText(summaryByItem)
setCopiedItem(true)
setTimeout(() => setCopiedItem(false), 2000)
}
return (
<div className="space-y-6 animate-in fade-in duration-500 pb-12">
{/* Top Breadcrumb Header */}
<div className="flex items-center gap-3">
<Link
href="/my-orders"
className="p-2 rounded-xl bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 text-slate-600 hover:text-[#1B2CC1] hover:border-[#1B2CC1] transition-all shadow-sm"
>
<ArrowLeft className="w-4 h-4" />
</Link>
<div>
<span className="text-xs font-semibold text-slate-400">Jasa Order Saya / Detail</span>
<h2 className="text-xl font-black text-slate-900 dark:text-white tracking-tight">
Rincian & Rekapitulasi PO
</h2>
</div>
</div>
{/* Edit Order Modal */}
{isCreator && isEditOpen && (
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
<EditDetailOrderModal
order={order}
onClose={() => setIsEditOpen(false)}
onSuccess={() => {
setIsEditOpen(false)
loadOrder()
}}
/>
</Dialog>
)}
{/* Delete Confirmation Modal */}
{isCreator && isDeleteOpen && (
<Dialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
<DialogContent className="sm:max-w-[440px] p-0 overflow-hidden rounded-3xl border-slate-200/90 dark:border-slate-800 shadow-2xl">
<div className="p-6 bg-red-50 dark:bg-red-950/30 border-b border-red-100 dark:border-red-900/50 flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-red-600 text-white flex items-center justify-center shrink-0 shadow-md shadow-red-600/20">
<AlertTriangle className="w-5 h-5" />
</div>
<div>
<DialogTitle className="text-lg font-black text-slate-900 dark:text-white">
Hapus Jasa PO Ini?
</DialogTitle>
<p className="text-xs text-slate-500 mt-0.5">
Tindakan ini permanen dan tidak dapat dibatalkan.
</p>
</div>
</div>
<div className="p-6 space-y-4 bg-white dark:bg-slate-900">
<div className="p-3.5 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200/80 dark:border-slate-700 text-xs">
<p className="font-bold text-slate-800 dark:text-slate-200 mb-1">{order.title}</p>
<p className="text-slate-500 text-[11px]">
Status: <span className="font-bold text-slate-700 dark:text-slate-300">{order.status}</span> {order.submissions.length} titipan pemesan
</p>
</div>
<p className="text-xs text-slate-600 dark:text-slate-400 leading-relaxed">
Seluruh data pesanan dan menu dalam PO ini akan dihapus. Anda akan dialihkan kembali ke daftar PO.
</p>
<div className="flex gap-2.5 pt-2">
<Button
type="button"
variant="outline"
onClick={() => setIsDeleteOpen(false)}
disabled={deleting}
className="w-1/2 h-10 rounded-xl font-bold text-xs"
>
Batal
</Button>
<Button
type="button"
onClick={handleDelete}
disabled={deleting}
className="w-1/2 h-10 rounded-xl bg-red-600 hover:bg-red-700 text-white font-bold text-xs shadow-md shadow-red-600/20 gap-1.5"
>
{deleting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
<span>{deleting ? 'Menghapus...' : 'Ya, Hapus PO'}</span>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)}
{/* Hero Overview Card */}
<Card className="rounded-3xl border border-slate-200/90 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm overflow-hidden">
<div className="p-6 sm:p-8 flex flex-col md:flex-row justify-between items-start md:items-center gap-4 border-b border-slate-100 dark:border-slate-800">
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className={cn(
"inline-flex items-center px-3 py-0.5 rounded-full text-xs font-black tracking-wide",
order.status === 'OPEN' ? 'bg-emerald-50 text-emerald-700 border border-emerald-200 dark:bg-emerald-950/50 dark:text-emerald-400' :
order.status === 'CLOSE' ? 'bg-rose-50 text-rose-700 border border-rose-200 dark:bg-rose-950/50 dark:text-rose-400' :
'bg-slate-100 text-slate-700 border border-slate-200 dark:bg-slate-800 dark:text-slate-300'
)}>
{order.status}
</span>
<span className="text-xs text-slate-400 font-medium">
{format(new Date(order.date), 'dd MMMM yyyy')}
</span>
</div>
<h1 className="text-2xl sm:text-3xl font-black text-slate-900 dark:text-white tracking-tight">
{order.title}
</h1>
<p className="text-xs text-slate-500 font-medium">
Dibuat oleh: <span className="text-slate-800 dark:text-slate-200 font-bold">{order.creator.name}</span>
</p>
</div>
<div className="flex items-center gap-4 bg-slate-50 dark:bg-slate-800/50 p-4 rounded-2xl border border-slate-200/80 dark:border-slate-700/60">
<div className="text-center px-2">
<span className="text-[10px] uppercase font-bold text-slate-400 block">Total Pemesan</span>
<span className="text-2xl font-black text-[#1B2CC1] dark:text-blue-400">{order.submissions.length}</span>
</div>
<div className="h-8 w-px bg-slate-200 dark:bg-slate-700" />
<div className="text-center px-2">
<span className="text-[10px] uppercase font-bold text-slate-400 block">Total Menu</span>
<span className="text-2xl font-black text-slate-800 dark:text-white">{order.available_items.length}</span>
</div>
</div>
</div>
{/* Creator Control Toolbar (Status, Edit, Delete) */}
{isCreator && (
<div className="p-4 sm:px-8 bg-slate-50/70 dark:bg-slate-800/40 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div className="flex items-center gap-2">
<ToggleLeft className="w-4 h-4 text-[#1B2CC1]" />
<span className="text-xs font-bold text-slate-700 dark:text-slate-300">
Aksi Creator PO:
</span>
</div>
<div className="flex items-center gap-2 flex-wrap">
{/* Edit Button (Available when not CLOSE) */}
{!isClosed && (
<Button
size="sm"
variant="outline"
onClick={() => setIsEditOpen(true)}
className="h-8.5 text-xs font-bold rounded-xl border-slate-200 dark:border-slate-700 text-[#1B2CC1] dark:text-blue-400 hover:bg-[#1B2CC1]/10 gap-1.5"
>
<Pencil className="w-3.5 h-3.5" />
<span>Edit Info PO</span>
</Button>
)}
{/* Status Change Buttons */}
{order.status === 'DRAFT' && (
<Button
size="sm"
onClick={() => handleStatusChange('OPEN')}
disabled={updatingStatus}
className="h-8.5 text-xs font-bold rounded-xl bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm gap-1.5"
>
{updatingStatus ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : null}
Buka PO (Jadikan OPEN)
</Button>
)}
{order.status === 'OPEN' && (
<Button
size="sm"
onClick={() => handleStatusChange('CLOSE')}
disabled={updatingStatus}
className="h-8.5 text-xs font-bold rounded-xl bg-rose-600 hover:bg-rose-700 text-white shadow-sm gap-1.5"
>
{updatingStatus ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : null}
Tutup PO (Jadikan CLOSE)
</Button>
)}
{order.status === 'CLOSE' && (
<Button
size="sm"
onClick={() => handleStatusChange('OPEN')}
disabled={updatingStatus}
className="h-8.5 text-xs font-bold rounded-xl bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm gap-1.5"
>
{updatingStatus ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : null}
Buka Kembali PO (Jadikan OPEN)
</Button>
)}
{/* Delete Button (Available when DRAFT or CLOSE) */}
{canDelete && (
<Button
size="sm"
variant="outline"
onClick={() => setIsDeleteOpen(true)}
className="h-8.5 text-xs font-bold rounded-xl border-red-200 dark:border-red-900/60 text-red-600 hover:bg-red-50 dark:hover:bg-red-950/30 gap-1.5"
>
<Trash2 className="w-3.5 h-3.5" />
<span>Hapus PO</span>
</Button>
)}
</div>
</div>
)}
</Card>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left 2 Cols: Submissions & Billing */}
<div className="lg:col-span-2 space-y-4">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between px-1 gap-4">
<h3 className="text-lg font-black text-slate-900 dark:text-white flex items-center gap-2">
<Users className="w-5 h-5 text-[#1B2CC1]" />
<span>Daftar Titipan Pemesan ({order.submissions.length})</span>
</h3>
<div className="flex items-center gap-2 w-full sm:w-auto">
{isCreator && !isClosed && (
<span className="hidden md:flex text-xs text-rose-700 dark:text-rose-400 font-medium bg-rose-50 dark:bg-rose-950/50 px-2.5 py-1 rounded-lg border border-rose-200/60 items-center gap-1 shrink-0">
<AlertCircle className="w-3.5 h-3.5" /> Ubah status ke CLOSE untuk mengatur tagihan
</span>
)}
<div className="relative w-full sm:w-48 shrink-0">
<div className="absolute inset-y-0 left-0 pl-2.5 flex items-center pointer-events-none">
<svg className="h-3.5 w-3.5 text-slate-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clipRule="evenodd" />
</svg>
</div>
<Input
type="text"
placeholder="Cari nama pemesan..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 h-9 w-full rounded-lg border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 focus-visible:ring-[#1B2CC1] text-xs"
/>
</div>
</div>
</div>
{order.submissions.length === 0 ? (
<div className="text-center p-12 rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm">
<p className="text-xs text-slate-500 font-medium">Belum ada orang yang menitip pada PO ini.</p>
</div>
) : (
<div className="space-y-4">
{(() => {
const filteredSubmissions = order.submissions.filter((sub: any) =>
sub.user.name.toLowerCase().includes(searchQuery.toLowerCase())
)
if (filteredSubmissions.length === 0) {
return (
<div className="text-center py-8 rounded-2xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">
<p className="text-xs text-slate-500 font-medium">Tidak ada pemesan yang cocok dengan pencarian.</p>
</div>
)
}
return filteredSubmissions.map((sub: any) => (
<SubmissionRow
key={sub.id}
sub={sub}
isCreator={isCreator}
isClosed={isClosed}
onUpdate={loadOrder}
/>
))
})()}
</div>
)}
</div>
{/* Right 1 Col: Summary Generators */}
<div className="space-y-6">
<div className="px-1">
<h3 className="text-lg font-black text-slate-900 dark:text-white flex items-center gap-2">
<Receipt className="w-5 h-5 text-[#1B2CC1]" />
<span>Generator Rekap</span>
</h3>
<p className="text-xs text-slate-500 mt-0.5">Salin format teks siap kirim ke WhatsApp / grup.</p>
</div>
{/* Rekap Per Orang */}
<Card className="rounded-2xl border border-slate-200/90 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm overflow-hidden">
<div className="p-4 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center bg-slate-50/50 dark:bg-slate-800/40">
<span className="text-xs font-bold text-slate-800 dark:text-slate-200">
Rekap per Orang
</span>
<Button
variant="outline"
size="sm"
onClick={handleCopyPerson}
className="h-8 text-xs font-bold rounded-lg border-slate-200 gap-1.5 hover:bg-[#1B2CC1] hover:text-white transition-all"
>
{copiedPerson ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copiedPerson ? 'Tersalin!' : 'Copy Text'}</span>
</Button>
</div>
<CardContent className="p-4">
<pre className="text-xs font-mono bg-slate-50 dark:bg-slate-800/80 p-3.5 rounded-xl overflow-x-auto whitespace-pre-wrap border border-slate-200/80 dark:border-slate-700 text-slate-700 dark:text-slate-300 leading-relaxed">
{summaryByPerson}
</pre>
</CardContent>
</Card>
{/* Rekap Per Item */}
<Card className="rounded-2xl border border-slate-200/90 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm overflow-hidden">
<div className="p-4 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center bg-slate-50/50 dark:bg-slate-800/40">
<span className="text-xs font-bold text-slate-800 dark:text-slate-200">
Rekap per Item (Akumulasi)
</span>
<Button
variant="outline"
size="sm"
onClick={handleCopyItem}
className="h-8 text-xs font-bold rounded-lg border-slate-200 gap-1.5 hover:bg-[#1B2CC1] hover:text-white transition-all"
>
{copiedItem ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copiedItem ? 'Tersalin!' : 'Copy Text'}</span>
</Button>
</div>
<CardContent className="p-4">
<pre className="text-xs font-mono bg-slate-50 dark:bg-slate-800/80 p-3.5 rounded-xl overflow-x-auto whitespace-pre-wrap border border-slate-200/80 dark:border-slate-700 text-slate-700 dark:text-slate-300 leading-relaxed">
{summaryByItem}
</pre>
</CardContent>
</Card>
</div>
</div>
</div>
)
}
function SubmissionRow({
sub,
isCreator,
isClosed,
onUpdate
}: {
sub: any
isCreator: boolean
isClosed: boolean
onUpdate: () => void
}) {
const [bill, setBill] = useState(sub.bill || '')
const [status, setStatus] = useState(sub.payment_status || 'BELUM_BAYAR')
const [saving, setSaving] = useState(false)
const handleSave = async () => {
setSaving(true)
await updateSubmissionPayment(sub.id, bill ? parseInt(bill) : null, status)
onUpdate()
setSaving(false)
}
const formatRupiah = (angka: number) => {
return new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(angka)
}
return (
<Card className="rounded-2xl border border-slate-200/90 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm overflow-hidden hover:border-[#1B2CC1]/30 transition-all">
<div className="p-5 flex flex-col md:flex-row gap-5 items-start md:items-center">
{/* Left: User & Order items */}
<div className="flex-1 space-y-3 w-full">
<div className="flex items-center gap-3">
{sub.user.photo ? (
<img src={sub.user.photo} alt={sub.user.name} className="w-10 h-10 rounded-full object-cover ring-2 ring-slate-100 dark:ring-slate-800" />
) : (
<div className="w-10 h-10 rounded-full bg-[#1B2CC1]/10 text-[#1B2CC1] flex items-center justify-center font-bold text-sm ring-2 ring-slate-100 dark:ring-slate-800">
{sub.user.name.charAt(0).toUpperCase()}
</div>
)}
<div>
<p className="font-bold text-slate-900 dark:text-white text-sm">{sub.user.name}</p>
<div className="flex items-center gap-2 mt-0.5">
<span className={cn(
"inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-black",
sub.payment_status === 'LUNAS'
? 'bg-emerald-50 text-emerald-700 border border-emerald-200'
: 'bg-rose-50 text-rose-700 border border-rose-200'
)}>
{sub.payment_status === 'LUNAS' ? '● LUNAS' : '● BELUM BAYAR'}
</span>
{sub.bill && (
<span className="text-xs font-black text-slate-700 dark:text-slate-300">
{formatRupiah(sub.bill)}
</span>
)}
</div>
</div>
</div>
<div className="bg-slate-50 dark:bg-slate-800/50 p-3 rounded-xl border border-slate-200/70 dark:border-slate-800">
<ul className="space-y-1 text-xs">
{sub.items.map((item: any) => (
<li key={item.id} className="flex justify-between items-center font-medium">
<span className="text-slate-700 dark:text-slate-300">
{item.name} {item.is_custom && <span className="text-[10px] font-bold text-[#1B2CC1] bg-blue-50 dark:bg-blue-950 px-1.5 py-0.5 rounded ml-1">Custom</span>}
</span>
<span className="font-black text-slate-900 dark:text-white">{item.qty}x</span>
</li>
))}
</ul>
</div>
</div>
{/* Right: Bill input if Creator */}
{isCreator && (
<div className="w-full md:w-60 border-t md:border-t-0 md:border-l pt-4 md:pt-0 md:pl-5 border-slate-200/80 dark:border-slate-800 space-y-2.5 shrink-0">
{isClosed ? (
<>
<div className="space-y-1">
<label className="text-[11px] font-bold uppercase tracking-wider text-slate-400">
Nominal Tagihan (Rp)
</label>
<Input
type="number"
placeholder="Contoh: 25000"
value={bill}
onChange={e => setBill(e.target.value)}
className="h-9 rounded-lg font-bold text-xs"
/>
</div>
<div className="space-y-1">
<label className="text-[11px] font-bold uppercase tracking-wider text-slate-400">
Status Bayar
</label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="h-9 rounded-lg text-xs font-semibold">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="BELUM_BAYAR">Belum Bayar</SelectItem>
<SelectItem value="LUNAS">Lunas</SelectItem>
</SelectContent>
</Select>
</div>
<Button
size="sm"
onClick={handleSave}
disabled={saving}
className="w-full h-9 rounded-lg bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold text-xs shadow-sm gap-1.5 mt-1"
>
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <><Save className="w-3.5 h-3.5" /> Simpan Tagihan</>}
</Button>
</>
) : (
<div className="p-3 bg-slate-50 dark:bg-slate-800/40 rounded-xl text-center border border-dashed border-slate-200 dark:border-slate-800">
<p className="text-[11px] text-slate-500 leading-tight">
Tagihan dapat diatur setelah status PO diubah menjadi <strong className="text-rose-600 dark:text-rose-400">CLOSE</strong> di atas.
</p>
</div>
)}
</div>
)}
</div>
</Card>
)
}
function EditDetailOrderModal({ order, onClose, onSuccess }: { order: any, onClose: () => void, onSuccess: () => void }) {
const [title, setTitle] = useState(order.title || '')
const [allowCustom, setAllowCustom] = useState(order.allow_custom || false)
const [items, setItems] = useState<Array<{ id: string, name: string }>>(
order.available_items?.length > 0
? order.available_items.map((ai: any) => ({ id: ai.id, name: ai.name }))
: [{ id: '1', name: '' }]
)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const addItem = () => setItems([...items, { id: Math.random().toString(), name: '' }])
const removeItem = (id: string) => setItems(items.filter(i => i.id !== id))
const updateItem = (id: string, name: string) => setItems(items.map(i => i.id === id ? { ...i, name } : i))
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (!title.trim()) return setError('Judul PO wajib diisi.')
const validItems = items.filter(i => i.name.trim()).map(i => i.name.trim())
if (!allowCustom && validItems.length === 0) {
return setError('Wajib menambahkan minimal 1 menu pilihan jika tidak mengizinkan custom item.')
}
setLoading(true)
const res = await updateOrder(order.id, {
title: title.trim(),
allow_custom: allowCustom,
available_items: validItems
})
if (res.success) {
onSuccess()
} else {
setError(res.error || 'Gagal memperbarui order.')
}
setLoading(false)
}
return (
<DialogContent className="sm:max-w-[540px] p-0 overflow-hidden rounded-3xl border-slate-200/90 dark:border-slate-800 shadow-2xl max-h-[90vh] flex flex-col">
<div className="bg-gradient-to-br from-[#1B2CC1] to-[#121E85] p-6 text-white">
<span className="text-[11px] font-bold uppercase tracking-wider text-blue-200">Edit Jasa PO</span>
<DialogTitle className="text-2xl font-black tracking-tight text-white mt-1">
Edit Informasi PO
</DialogTitle>
<p className="text-xs text-blue-100 mt-1">
Perbarui judul pesanan atau daftar menu yang dapat dipilih pemesan.
</p>
</div>
<form onSubmit={handleSubmit} className="p-6 overflow-y-auto space-y-6 flex-1 bg-white dark:bg-slate-900">
<div className="space-y-1.5">
<Label htmlFor="detail-edit-title" className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300">
Judul PO <span className="text-red-500">*</span>
</Label>
<Input
id="detail-edit-title"
placeholder="Contoh: Titip Kopi Tuku Lebak Bulus"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="h-11 rounded-xl border-slate-200 dark:border-slate-800 focus-visible:ring-4 focus-visible:ring-[#1B2CC1]/15 focus-visible:border-[#1B2CC1]"
/>
</div>
{/* Custom Item Checkbox */}
<div className="flex items-start space-x-3 p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/70 dark:bg-slate-800/40">
<Checkbox
id="detail-edit-allowCustom"
checked={allowCustom}
onCheckedChange={(c) => setAllowCustom(c as boolean)}
className="rounded-lg mt-0.5 data-[state=checked]:bg-[#1B2CC1] data-[state=checked]:border-[#1B2CC1]"
/>
<div className="grid gap-0.5">
<Label htmlFor="detail-edit-allowCustom" className="text-xs font-bold text-slate-800 dark:text-slate-200 cursor-pointer">
Izinkan Item Custom / Kustom
</Label>
<p className="text-[11px] text-slate-500">
Teman bisa menambahkan nama pesanan lain di luar daftar menu yang Anda buat.
</p>
</div>
</div>
{/* Dynamic Items List */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300">
Daftar Menu Pilihan
</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={addItem}
className="h-8 text-xs font-bold rounded-lg border-dashed border-[#1B2CC1]/50 text-[#1B2CC1] hover:bg-[#1B2CC1]/10 gap-1.5"
>
<PlusCircle className="w-3.5 h-3.5" /> Tambah Baris
</Button>
</div>
<div className="space-y-2.5">
{items.map((item, index) => (
<div key={item.id} className="flex gap-2 items-center p-2 rounded-xl border border-slate-200/90 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/30">
<span className="text-xs font-bold text-slate-400 w-5 text-center">{index + 1}.</span>
<Input
placeholder="Nama menu (mis: Es Kopi Susu Tetangga)"
value={item.name}
onChange={(e) => updateItem(item.id, e.target.value)}
className="flex-1 h-9 rounded-lg bg-white dark:bg-slate-900 text-xs font-medium"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeItem(item.id)}
disabled={items.length === 1 && !allowCustom}
className="h-8 w-8 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-lg"
>
<MinusCircle className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
{error && (
<div className="p-3 bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900 rounded-xl text-xs text-red-600 dark:text-red-400 font-semibold">
{error}
</div>
)}
<div className="flex gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={onClose}
className="w-1/3 h-11 rounded-xl font-bold"
>
Batal
</Button>
<Button
type="submit"
disabled={loading}
className="flex-1 h-11 rounded-xl bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold shadow-md shadow-[#1B2CC1]/25"
>
{loading ? 'Menyimpan Perubahan...' : 'Simpan Perubahan PO'}
</Button>
</div>
</form>
</DialogContent>
)
}
+813
View File
@@ -0,0 +1,813 @@
'use client'
import { useEffect, useState } from 'react'
import { getMyOrders, createOrder, updateOrder, duplicateOrder, updateOrderStatus, deleteOrder } from '@/app/actions'
import { Button, buttonVariants } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import { format } from 'date-fns'
import { id as idLocale } from 'date-fns/locale'
import Link from 'next/link'
import {
PlusCircle,
MinusCircle,
Copy,
FileText,
Loader2,
Plus,
Users,
Sparkles,
Layers,
Pencil,
Lock,
Trash2,
AlertTriangle,
ChevronLeft,
ChevronRight
} from 'lucide-react'
export default function MyOrdersPage() {
const [userId, setUserId] = useState<string | null>(null)
const [orders, setOrders] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [isCreateOpen, setIsCreateOpen] = useState(false)
const [editingOrder, setEditingOrder] = useState<any | null>(null)
const [orderToDelete, setOrderToDelete] = useState<any | null>(null)
const [orderToDuplicate, setOrderToDuplicate] = useState<any | null>(null)
const [deleting, setDeleting] = useState(false)
const [activeTab, setActiveTab] = useState<'ALL' | 'OPEN' | 'DRAFT' | 'CLOSE'>('ALL')
const [currentPage, setCurrentPage] = useState(1)
const [searchQuery, setSearchQuery] = useState('')
const ITEMS_PER_PAGE = 5
useEffect(() => {
const id = localStorage.getItem('user_id')
if (id) {
setUserId(id)
loadOrders(id)
}
}, [])
const loadOrders = async (id: string) => {
setLoading(true)
const data = await getMyOrders(id)
setOrders(data)
setLoading(false)
}
const handleStatusChange = async (orderId: string, newStatus: string) => {
const res = await updateOrderStatus(orderId, newStatus)
if (res.success && userId) {
loadOrders(userId)
}
}
const handleDelete = async () => {
if (!orderToDelete) return
setDeleting(true)
const res = await deleteOrder(orderToDelete.id)
if (res.success && userId) {
setOrderToDelete(null)
loadOrders(userId)
}
setDeleting(false)
}
const getStatusBadge = (status: string) => {
switch(status) {
case 'OPEN':
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-black bg-emerald-50 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-400 border border-emerald-200/80">
OPEN
</span>
)
case 'CLOSE':
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-black bg-rose-50 text-rose-700 dark:bg-rose-950/50 dark:text-rose-400 border border-rose-200/80">
CLOSE
</span>
)
default:
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-black bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300 border border-slate-200/90">
DRAFT
</span>
)
}
}
const filteredOrders = orders.filter(o => {
const matchTab = activeTab === 'ALL' ? true : o.status === activeTab
const matchSearch = o.title.toLowerCase().includes(searchQuery.toLowerCase())
return matchTab && matchSearch
})
// Pagination logic
const totalPages = Math.ceil(filteredOrders.length / ITEMS_PER_PAGE)
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE
const paginatedOrders = filteredOrders.slice(startIndex, startIndex + ITEMS_PER_PAGE)
// Reset page when tab or search changes
useEffect(() => {
setCurrentPage(1)
}, [activeTab, searchQuery])
if (loading) {
return (
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-3">
<Loader2 className="w-8 h-8 animate-spin text-[#1B2CC1]" />
<span className="text-xs text-slate-500 font-semibold">Memuat data PO Anda...</span>
</div>
)
}
return (
<div className="space-y-6 animate-in fade-in duration-500 pb-12">
{/* Top Controls & Segmented Control */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 bg-white dark:bg-slate-900 p-4 rounded-2xl border border-slate-200/80 dark:border-slate-800 shadow-sm">
<div className="flex flex-col md:flex-row items-start md:items-center gap-4 w-full md:w-auto">
{/* Segmented Control / Tabs */}
<div className="flex items-center bg-slate-100 dark:bg-slate-800/80 p-1 rounded-xl text-xs font-bold w-full md:w-auto overflow-x-auto">
{(['ALL', 'OPEN', 'DRAFT', 'CLOSE'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={cn(
"px-4 py-2 rounded-lg transition-all duration-200 whitespace-nowrap",
activeTab === tab
? "bg-white dark:bg-slate-900 text-[#1B2CC1] dark:text-white shadow-sm font-black"
: "text-slate-500 hover:text-slate-900 dark:hover:text-white"
)}
>
{tab === 'ALL' ? 'Semua PO' : tab}
<span className="ml-1.5 text-[10px] opacity-70">
({tab === 'ALL' ? orders.length : orders.filter(o => o.status === tab).length})
</span>
</button>
))}
</div>
{/* Search Input */}
<div className="relative w-full md:w-64">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-4 w-4 text-slate-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clipRule="evenodd" />
</svg>
</div>
<Input
type="text"
placeholder="Cari nama PO..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-10 w-full rounded-xl border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-900 focus-visible:ring-[#1B2CC1] text-sm"
/>
</div>
</div>
{/* Action Button */}
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger className={cn(
buttonVariants({ size: "lg" }),
"w-full md:w-auto h-10 rounded-xl bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold shadow-md shadow-[#1B2CC1]/20 gap-2 cursor-pointer"
)}>
<Plus className="w-4 h-4" /> Buka Jasa PO Baru
</DialogTrigger>
<CreateOrderModal
userId={userId!}
onSuccess={() => {
setIsCreateOpen(false)
if (userId) loadOrders(userId)
}}
/>
</Dialog>
</div>
{/* Edit Order Modal */}
{editingOrder && (
<Dialog open={!!editingOrder} onOpenChange={(open) => !open && setEditingOrder(null)}>
<EditOrderModal
order={editingOrder}
onClose={() => setEditingOrder(null)}
onSuccess={() => {
setEditingOrder(null)
if (userId) loadOrders(userId)
}}
/>
</Dialog>
)}
{/* Duplicate Order Modal */}
{orderToDuplicate && (
<Dialog open={!!orderToDuplicate} onOpenChange={(open) => !open && setOrderToDuplicate(null)}>
<CreateOrderModal
userId={userId!}
initialData={orderToDuplicate}
onSuccess={() => {
setOrderToDuplicate(null)
if (userId) loadOrders(userId)
}}
/>
</Dialog>
)}
{/* Delete Confirmation Modal */}
{orderToDelete && (
<Dialog open={!!orderToDelete} onOpenChange={(open) => !open && setOrderToDelete(null)}>
<DialogContent className="sm:max-w-[440px] p-0 overflow-hidden rounded-3xl border-slate-200/90 dark:border-slate-800 shadow-2xl">
<div className="p-6 bg-red-50 dark:bg-red-950/30 border-b border-red-100 dark:border-red-900/50 flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-red-600 text-white flex items-center justify-center shrink-0 shadow-md shadow-red-600/20">
<AlertTriangle className="w-5 h-5" />
</div>
<div>
<DialogTitle className="text-lg font-black text-slate-900 dark:text-white">
Hapus Jasa PO Ini?
</DialogTitle>
<p className="text-xs text-slate-500 mt-0.5">
Tindakan ini permanen dan tidak dapat dibatalkan.
</p>
</div>
</div>
<div className="p-6 space-y-4 bg-white dark:bg-slate-900">
<div className="p-3.5 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200/80 dark:border-slate-700 text-xs">
<p className="font-bold text-slate-800 dark:text-slate-200 mb-1">{orderToDelete.title}</p>
<p className="text-slate-500 text-[11px]">
Status: <span className="font-bold text-slate-700 dark:text-slate-300">{orderToDelete.status}</span> {orderToDelete.submissions?.length || 0} titipan pemesan
</p>
</div>
<p className="text-xs text-slate-600 dark:text-slate-400 leading-relaxed">
Seluruh data pesanan dan daftar menu yang ada di dalam PO ini akan dihapus dari sistem.
</p>
<div className="flex gap-2.5 pt-2">
<Button
type="button"
variant="outline"
onClick={() => setOrderToDelete(null)}
disabled={deleting}
className="w-1/2 h-10 rounded-xl font-bold text-xs"
>
Batal
</Button>
<Button
type="button"
onClick={handleDelete}
disabled={deleting}
className="w-1/2 h-10 rounded-xl bg-red-600 hover:bg-red-700 text-white font-bold text-xs shadow-md shadow-red-600/20 gap-1.5"
>
{deleting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
<span>{deleting ? 'Menghapus...' : 'Ya, Hapus PO'}</span>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)}
{/* Order List */}
{filteredOrders.length === 0 ? (
<div className="flex flex-col items-center justify-center p-16 text-center rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm">
<div className="w-16 h-16 bg-[#1B2CC1]/10 rounded-2xl flex items-center justify-center mb-4 text-[#1B2CC1]">
<Layers className="w-8 h-8" />
</div>
<h3 className="text-lg font-bold text-slate-800 dark:text-slate-200 mb-1">
{activeTab === 'ALL' ? 'Belum Ada Jasa PO' : `Tidak Ada PO Berstatus ${activeTab}`}
</h3>
<p className="text-xs text-slate-500 max-w-sm mb-6 leading-relaxed">
Mulai buka jasa titip pesanan makanan atau kebutuhan untuk teman kantor.
</p>
<Button
onClick={() => setIsCreateOpen(true)}
className="bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold rounded-xl shadow-md shadow-[#1B2CC1]/20 gap-2 h-10"
>
<Plus className="w-4 h-4" /> Buat PO Baru
</Button>
</div>
) : (
<div className="flex flex-col h-full space-y-4">
<div className="flex flex-col space-y-4 max-h-[calc(100vh-260px)] overflow-y-auto pr-2 scrollbar-thin">
{paginatedOrders.map((order) => {
const date = new Date(order.date)
const isClosed = order.status === 'CLOSE'
const isDraft = order.status === 'DRAFT'
const canDelete = isDraft || isClosed
return (
<div
key={order.id}
className="flex flex-col md:flex-row h-full md:h-auto rounded-2xl border border-slate-200/90 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm hover:shadow-md hover:border-[#1B2CC1]/40 transition-all overflow-hidden"
>
{/* Left: Info */}
<div className="flex-1 p-5 md:border-r border-slate-100 dark:border-slate-800/80 flex flex-col justify-center">
<div className="flex justify-between items-start gap-4">
<h3 className="text-xl font-bold text-slate-900 dark:text-white line-clamp-2 leading-snug flex-1 min-w-0">
{order.title}
</h3>
<div className="flex flex-col sm:flex-row items-end sm:items-center gap-2 shrink-0">
<span className="text-[11px] text-slate-500 font-semibold bg-slate-100/80 dark:bg-slate-800 px-2.5 py-1 rounded-full whitespace-nowrap">
{format(date, 'EEEE, dd MMM yyyy', { locale: idLocale })}
</span>
{getStatusBadge(order.status)}
</div>
</div>
<div className="flex flex-wrap items-center gap-3 mt-3 pt-3 border-t border-slate-100 dark:border-slate-800/60">
<div className="flex items-center gap-1.5">
<Users className="w-4 h-4 text-[#1B2CC1]" />
<span className="text-xs font-bold text-slate-700 dark:text-slate-300">
{order.submissions.length} Orang Menitip
</span>
</div>
<div className="w-1 h-1 rounded-full bg-slate-300 dark:bg-slate-600 hidden sm:block"></div>
<div className="flex items-center gap-2">
{order.available_items.length > 0 && (
<span className="text-xs font-bold text-slate-600 dark:text-slate-400">
{order.available_items.length} Menu Pilihan
</span>
)}
{order.allow_custom && (
<div className="inline-flex items-center gap-1 text-[10px] font-semibold text-[#1B2CC1] bg-blue-50 dark:bg-blue-950/40 px-2 py-0.5 rounded-md">
<Sparkles className="w-3 h-3" /> Custom Item Aktif
</div>
)}
</div>
</div>
</div>
{/* Right: Actions */}
<div className="w-full md:w-64 p-5 bg-slate-50/70 dark:bg-slate-800/40 flex flex-col justify-center gap-3 md:border-l border-slate-100 dark:border-slate-800/80 mt-auto md:mt-0">
<div className="grid grid-cols-2 gap-2 w-full">
{/* Detail Link */}
<Link
href={`/my-orders/${order.id}`}
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"w-full h-10 rounded-xl border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 font-bold text-xs gap-1.5 hover:bg-blue-50/60 hover:text-[#1B2CC1] hover:border-[#1B2CC1]/40 shadow-2xs transition-all flex items-center justify-center px-2"
)}
>
<FileText className="w-4 h-4 text-[#1B2CC1] shrink-0" />
<span className="truncate">Detail</span>
</Link>
{/* Status Action Button */}
<div className="w-full">
{order.status === 'DRAFT' && (
<Button
onClick={() => handleStatusChange(order.id, 'OPEN')}
className="w-full h-10 rounded-xl bg-emerald-600 hover:bg-emerald-700 text-white font-bold text-xs shadow-sm gap-1.5 transition-all cursor-pointer px-2"
title="Buka PO"
>
<Sparkles className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">Buka</span>
</Button>
)}
{order.status === 'OPEN' && (
<Button
onClick={() => handleStatusChange(order.id, 'CLOSE')}
className="w-full h-10 rounded-xl bg-rose-600 hover:bg-rose-700 text-white font-bold text-xs shadow-sm gap-1.5 transition-all cursor-pointer px-2"
title="Tutup PO"
>
<Lock className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">Tutup</span>
</Button>
)}
{order.status === 'CLOSE' && (
<Button
onClick={() => handleStatusChange(order.id, 'OPEN')}
className="w-full h-10 rounded-xl bg-emerald-600 hover:bg-emerald-700 text-white font-bold text-xs shadow-sm gap-1.5 transition-all cursor-pointer px-2"
title="Buka Kembali PO"
>
<Sparkles className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">Buka</span>
</Button>
)}
</div>
</div>
{/* Utility Tools Row */}
<div className="grid grid-cols-3 gap-1.5 pt-2 border-t border-slate-200/60 dark:border-slate-800/60">
<Button
variant="ghost"
size="sm"
disabled={isClosed}
onClick={() => setEditingOrder(order)}
className={cn(
"h-8 rounded-lg font-bold text-[11px] gap-1 px-1.5 transition-all",
isClosed
? "opacity-35 cursor-not-allowed text-slate-400"
: "text-slate-600 hover:text-[#1B2CC1] hover:bg-[#1B2CC1]/10 dark:text-slate-300"
)}
title={isClosed ? "PO sudah CLOSE" : "Edit PO"}
>
<Pencil className="w-3.5 h-3.5" />
<span>Edit</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setOrderToDuplicate(order)}
className="h-8 rounded-lg font-bold text-[11px] gap-1 px-1.5 text-slate-600 hover:text-slate-900 hover:bg-slate-200/60 dark:text-slate-300 transition-all"
title="Duplikasi PO ini"
>
<Copy className="w-3.5 h-3.5" />
<span>Duplikat</span>
</Button>
<Button
variant="ghost"
size="sm"
disabled={!canDelete}
onClick={() => setOrderToDelete(order)}
className={cn(
"h-8 rounded-lg font-bold text-[11px] gap-1 px-1.5 transition-all",
!canDelete
? "opacity-35 cursor-not-allowed text-slate-400"
: "text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/40"
)}
title={!canDelete ? "PO OPEN tidak dapat dihapus" : "Hapus PO"}
>
<Trash2 className="w-3.5 h-3.5" />
<span>Hapus</span>
</Button>
</div>
</div>
</div>
)
})}
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-6 mt-4 border-t border-slate-200 dark:border-slate-800">
<span className="text-xs text-slate-500 font-medium">
Menampilkan <span className="font-bold text-slate-900 dark:text-white">{(currentPage - 1) * ITEMS_PER_PAGE + 1}</span> hingga <span className="font-bold text-slate-900 dark:text-white">{Math.min(currentPage * ITEMS_PER_PAGE, filteredOrders.length)}</span> dari <span className="font-bold text-slate-900 dark:text-white">{filteredOrders.length}</span> PO
</span>
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className="h-8 w-8 p-0 rounded-lg"
>
<ChevronLeft className="w-4 h-4" />
</Button>
<div className="flex items-center gap-1 px-2">
{Array.from({ length: totalPages }, (_, i) => i + 1).map(page => (
<Button
key={page}
variant="ghost"
size="sm"
onClick={() => setCurrentPage(page)}
className={cn(
"h-8 w-8 p-0 rounded-lg text-xs font-bold transition-all",
currentPage === page
? "bg-[#1B2CC1] text-white hover:bg-[#15229E] hover:text-white"
: "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
)}
>
{page}
</Button>
))}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
className="h-8 w-8 p-0 rounded-lg"
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</div>
)}
</div>
)
}
function CreateOrderModal({ userId, onSuccess, initialData }: { userId: string, onSuccess: () => void, initialData?: any }) {
const [title, setTitle] = useState(initialData ? `${initialData.title} (Copy)` : '')
const [allowCustom, setAllowCustom] = useState(initialData ? initialData.allow_custom : false)
const [items, setItems] = useState(
initialData && initialData.available_items?.length > 0
? initialData.available_items.map((ai: any) => ({ id: Math.random().toString(), name: ai.name }))
: [{ id: '1', name: '' }]
)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const addItem = () => setItems([...items, { id: Math.random().toString(), name: '' }])
const removeItem = (id: string) => setItems(items.filter(i => i.id !== id))
const updateItem = (id: string, name: string) => setItems(items.map(i => i.id === id ? { ...i, name } : i))
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (!title.trim()) return setError('Judul PO wajib diisi.')
const validItems = items.filter(i => i.name.trim()).map(i => i.name.trim())
if (!allowCustom && validItems.length === 0) {
return setError('Wajib menambahkan minimal 1 menu pilihan jika tidak mengizinkan custom item.')
}
setLoading(true)
const res = await createOrder({
creator_id: userId,
title: title.trim(),
date: new Date(),
allow_custom: allowCustom,
available_items: validItems
})
if (res.success) {
onSuccess()
} else {
setError(res.error || 'Gagal membuat order')
}
setLoading(false)
}
return (
<DialogContent
closeClassName="text-white/80 hover:text-slate-900 hover:bg-white/90"
className="sm:max-w-[540px] p-0 overflow-hidden rounded-3xl border-slate-200/90 dark:border-slate-800 shadow-2xl max-h-[90vh] flex flex-col"
>
<div className="bg-gradient-to-br from-[#1B2CC1] to-[#121E85] p-6 text-white">
<span className="text-[11px] font-bold uppercase tracking-wider text-blue-200">Form Buka PO</span>
<DialogTitle className="text-2xl font-black tracking-tight text-white mt-1">
Buka Jasa Titip Baru
</DialogTitle>
<p className="text-xs text-blue-100 mt-1">
Tentukan nama pesanan dan daftar menu yang bisa dipesan teman kantor.
</p>
</div>
<form onSubmit={handleSubmit} className="p-6 overflow-y-auto space-y-6 flex-1 bg-white dark:bg-slate-900">
<div className="space-y-1.5">
<Label htmlFor="order-title" className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300">
Judul PO <span className="text-red-500">*</span>
</Label>
<Input
id="order-title"
placeholder="Contoh: Titip Kopi Tuku Lebak Bulus"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="h-11 rounded-xl border-slate-200 dark:border-slate-800 focus-visible:ring-4 focus-visible:ring-[#1B2CC1]/15 focus-visible:border-[#1B2CC1]"
/>
</div>
{/* Custom Item Checkbox */}
<div className="flex items-start space-x-3 p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/70 dark:bg-slate-800/40">
<Checkbox
id="allowCustom"
checked={allowCustom}
onCheckedChange={(c) => setAllowCustom(c as boolean)}
className="rounded-lg mt-0.5 data-[state=checked]:bg-[#1B2CC1] data-[state=checked]:border-[#1B2CC1]"
/>
<div className="grid gap-0.5">
<Label htmlFor="allowCustom" className="text-xs font-bold text-slate-800 dark:text-slate-200 cursor-pointer">
Izinkan Item Custom / Kustom
</Label>
<p className="text-[11px] text-slate-500">
Teman bisa menambahkan nama pesanan lain di luar daftar menu yang Anda buat.
</p>
</div>
</div>
{/* Dynamic Items List */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300">
Daftar Menu Pilihan
</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={addItem}
className="h-8 text-xs font-bold rounded-lg border-dashed border-[#1B2CC1]/50 text-[#1B2CC1] hover:bg-[#1B2CC1]/10 gap-1.5"
>
<PlusCircle className="w-3.5 h-3.5" /> Tambah Baris
</Button>
</div>
<div className="space-y-2.5">
{items.map((item, index) => (
<div key={item.id} className="flex gap-2 items-center p-2 rounded-xl border border-slate-200/90 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/30">
<span className="text-xs font-bold text-slate-400 w-5 text-center">{index + 1}.</span>
<Input
placeholder="Nama menu (mis: Es Kopi Susu Tetangga)"
value={item.name}
onChange={(e) => updateItem(item.id, e.target.value)}
className="flex-1 h-9 rounded-lg bg-white dark:bg-slate-900 text-xs font-medium"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeItem(item.id)}
disabled={items.length === 1 && !allowCustom}
className="h-8 w-8 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-lg"
>
<MinusCircle className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
{error && (
<div className="p-3 bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900 rounded-xl text-xs text-red-600 dark:text-red-400 font-semibold">
{error}
</div>
)}
<Button
type="submit"
disabled={loading}
className="w-full h-11 rounded-xl bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold shadow-md shadow-[#1B2CC1]/25"
>
{loading ? 'Menyimpan PO...' : 'Simpan & Buat PO (Otomatis DRAFT)'}
</Button>
</form>
</DialogContent>
)
}
function EditOrderModal({ order, onClose, onSuccess }: { order: any, onClose: () => void, onSuccess: () => void }) {
const [title, setTitle] = useState(order.title || '')
const [allowCustom, setAllowCustom] = useState(order.allow_custom || false)
const [items, setItems] = useState<Array<{ id: string, name: string }>>(
order.available_items?.length > 0
? order.available_items.map((ai: any) => ({ id: ai.id, name: ai.name }))
: [{ id: '1', name: '' }]
)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const addItem = () => setItems([...items, { id: Math.random().toString(), name: '' }])
const removeItem = (id: string) => setItems(items.filter(i => i.id !== id))
const updateItem = (id: string, name: string) => setItems(items.map(i => i.id === id ? { ...i, name } : i))
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (!title.trim()) return setError('Judul PO wajib diisi.')
const validItems = items.filter(i => i.name.trim()).map(i => i.name.trim())
if (!allowCustom && validItems.length === 0) {
return setError('Wajib menambahkan minimal 1 menu pilihan jika tidak mengizinkan custom item.')
}
setLoading(true)
const res = await updateOrder(order.id, {
title: title.trim(),
allow_custom: allowCustom,
available_items: validItems
})
if (res.success) {
onSuccess()
} else {
setError(res.error || 'Gagal memperbarui order.')
}
setLoading(false)
}
return (
<DialogContent
closeClassName="text-white/80 hover:text-slate-900 hover:bg-white/90"
className="sm:max-w-[540px] p-0 overflow-hidden rounded-3xl border-slate-200/90 dark:border-slate-800 shadow-2xl max-h-[90vh] flex flex-col"
>
<div className="bg-gradient-to-br from-[#1B2CC1] to-[#121E85] p-6 text-white">
<span className="text-[11px] font-bold uppercase tracking-wider text-blue-200">Edit Jasa PO</span>
<DialogTitle className="text-2xl font-black tracking-tight text-white mt-1">
Edit Informasi PO
</DialogTitle>
<p className="text-xs text-blue-100 mt-1">
Perbarui judul pesanan atau daftar menu yang dapat dipilih pemesan.
</p>
</div>
<form onSubmit={handleSubmit} className="p-6 overflow-y-auto space-y-6 flex-1 bg-white dark:bg-slate-900">
<div className="space-y-1.5">
<Label htmlFor="edit-order-title" className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300">
Judul PO <span className="text-red-500">*</span>
</Label>
<Input
id="edit-order-title"
placeholder="Contoh: Titip Kopi Tuku Lebak Bulus"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="h-11 rounded-xl border-slate-200 dark:border-slate-800 focus-visible:ring-4 focus-visible:ring-[#1B2CC1]/15 focus-visible:border-[#1B2CC1]"
/>
</div>
{/* Custom Item Checkbox */}
<div className="flex items-start space-x-3 p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/70 dark:bg-slate-800/40">
<Checkbox
id="edit-allowCustom"
checked={allowCustom}
onCheckedChange={(c) => setAllowCustom(c as boolean)}
className="rounded-lg mt-0.5 data-[state=checked]:bg-[#1B2CC1] data-[state=checked]:border-[#1B2CC1]"
/>
<div className="grid gap-0.5">
<Label htmlFor="edit-allowCustom" className="text-xs font-bold text-slate-800 dark:text-slate-200 cursor-pointer">
Izinkan Item Custom / Kustom
</Label>
<p className="text-[11px] text-slate-500">
Teman bisa menambahkan nama pesanan lain di luar daftar menu yang Anda buat.
</p>
</div>
</div>
{/* Dynamic Items List */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300">
Daftar Menu Pilihan
</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={addItem}
className="h-8 text-xs font-bold rounded-lg border-dashed border-[#1B2CC1]/50 text-[#1B2CC1] hover:bg-[#1B2CC1]/10 gap-1.5"
>
<PlusCircle className="w-3.5 h-3.5" /> Tambah Baris
</Button>
</div>
<div className="space-y-2.5">
{items.map((item, index) => (
<div key={item.id} className="flex gap-2 items-center p-2 rounded-xl border border-slate-200/90 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/30">
<span className="text-xs font-bold text-slate-400 w-5 text-center">{index + 1}.</span>
<Input
placeholder="Nama menu (mis: Es Kopi Susu Tetangga)"
value={item.name}
onChange={(e) => updateItem(item.id, e.target.value)}
className="flex-1 h-9 rounded-lg bg-white dark:bg-slate-900 text-xs font-medium"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeItem(item.id)}
disabled={items.length === 1 && !allowCustom}
className="h-8 w-8 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-lg"
>
<MinusCircle className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
{error && (
<div className="p-3 bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900 rounded-xl text-xs text-red-600 dark:text-red-400 font-semibold">
{error}
</div>
)}
<div className="flex gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={onClose}
className="w-1/3 h-11 rounded-xl font-bold"
>
Batal
</Button>
<Button
type="submit"
disabled={loading}
className="flex-1 h-11 rounded-xl bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold shadow-md shadow-[#1B2CC1]/25"
>
{loading ? 'Menyimpan Perubahan...' : 'Simpan Perubahan PO'}
</Button>
</div>
</form>
</DialogContent>
)
}
+647
View File
@@ -0,0 +1,647 @@
'use client'
import { useEffect, useState } from 'react'
import { getMyPurchases, submitOrder, getUserSubmission } from '@/app/actions'
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'
import { Button, buttonVariants } from '@/components/ui/button'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { format } from 'date-fns'
import { id as idLocale } from 'date-fns/locale'
import {
Loader2,
Package,
ShoppingBag,
ArrowRight,
Pencil,
PlusCircle,
MinusCircle,
Lock,
Sparkles,
CheckCircle2,
ChevronLeft,
ChevronRight
} from 'lucide-react'
import Link from 'next/link'
export default function MyPurchasesPage() {
const [userId, setUserId] = useState<string | null>(null)
const [purchases, setPurchases] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState<'ALL' | 'BELUM_BAYAR' | 'LUNAS'>('ALL')
const [editingPurchase, setEditingPurchase] = useState<any | null>(null)
const [currentPage, setCurrentPage] = useState(1)
const [searchQuery, setSearchQuery] = useState('')
const ITEMS_PER_PAGE = 5
useEffect(() => {
const id = localStorage.getItem('user_id')
if (id) {
setUserId(id)
loadPurchases(id)
}
}, [])
const loadPurchases = async (id: string) => {
setLoading(true)
const data = await getMyPurchases(id)
setPurchases(data)
setLoading(false)
}
const formatRupiah = (angka: number) => {
return new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(angka)
}
const filteredPurchases = purchases.filter(p => {
const matchFilter = filter === 'ALL' ? true : p.payment_status === filter
const searchLower = searchQuery.toLowerCase()
const matchSearch = p.order.title.toLowerCase().includes(searchLower) || p.order.creator.name.toLowerCase().includes(searchLower)
return matchFilter && matchSearch
})
// Pagination logic
const totalPages = Math.ceil(filteredPurchases.length / ITEMS_PER_PAGE)
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE
const paginatedPurchases = filteredPurchases.slice(startIndex, startIndex + ITEMS_PER_PAGE)
// Reset page when filter or search changes
useEffect(() => {
setCurrentPage(1)
}, [filter, searchQuery])
if (loading) {
return (
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-3">
<Loader2 className="w-8 h-8 animate-spin text-[#1B2CC1]" />
<span className="text-xs text-slate-500 font-semibold">Memuat riwayat titipan Anda...</span>
</div>
)
}
return (
<div className="space-y-6 animate-in fade-in duration-500 pb-12">
{/* Top Filter Bar */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 bg-white dark:bg-slate-900 p-4 rounded-2xl border border-slate-200/80 dark:border-slate-800 shadow-sm">
<div className="flex flex-col md:flex-row items-start md:items-center gap-4 w-full md:w-auto">
<div className="flex items-center bg-slate-100 dark:bg-slate-800/80 p-1 rounded-xl text-xs font-bold w-full md:w-auto overflow-x-auto">
{(['ALL', 'BELUM_BAYAR', 'LUNAS'] as const).map((tab) => (
<button
key={tab}
onClick={() => setFilter(tab)}
className={cn(
"px-4 py-2 rounded-lg transition-all duration-200 whitespace-nowrap",
filter === tab
? "bg-white dark:bg-slate-900 text-[#1B2CC1] dark:text-white shadow-sm font-black"
: "text-slate-500 hover:text-slate-900 dark:hover:text-white"
)}
>
{tab === 'ALL' ? 'Semua Titipan' : tab === 'BELUM_BAYAR' ? 'Belum Bayar' : 'Lunas'}
<span className="ml-1.5 text-[10px] opacity-70">
({tab === 'ALL' ? purchases.length : purchases.filter(p => p.payment_status === tab).length})
</span>
</button>
))}
</div>
{/* Search Input */}
<div className="relative w-full md:w-64">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-4 w-4 text-slate-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clipRule="evenodd" />
</svg>
</div>
<Input
type="text"
placeholder="Cari nama PO / pembuat..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-10 w-full rounded-xl border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-900 focus-visible:ring-[#1B2CC1] text-sm"
/>
</div>
</div>
<Link
href="/"
className={cn(
buttonVariants(),
"bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold rounded-xl h-10 px-4 gap-1.5 shadow-sm w-full md:w-auto"
)}
>
<ShoppingBag className="w-4 h-4" /> Cari PO Terbuka
</Link>
</div>
{/* Edit Purchase Modal */}
{editingPurchase && (
<Dialog open={!!editingPurchase} onOpenChange={(open) => !open && setEditingPurchase(null)}>
<EditPurchaseModal
order={editingPurchase.order}
userId={userId!}
onClose={() => setEditingPurchase(null)}
onSuccess={() => {
setEditingPurchase(null)
if (userId) loadPurchases(userId)
}}
/>
</Dialog>
)}
{filteredPurchases.length === 0 ? (
<div className="flex flex-col items-center justify-center p-16 text-center rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm">
<div className="w-16 h-16 bg-[#1B2CC1]/10 rounded-2xl flex items-center justify-center mb-4 text-[#1B2CC1]">
<Package className="w-8 h-8" />
</div>
<h3 className="text-lg font-bold text-slate-800 dark:text-slate-200 mb-1">
{filter === 'ALL' ? 'Belum Ada Riwayat Titipan' : `Tidak Ada Titipan Berstatus ${filter}`}
</h3>
<p className="text-xs text-slate-500 max-w-sm mb-6 leading-relaxed">
Anda belum pernah menitip pesanan ke PO teman. Jelajahi menu PO yang sedang buka hari ini.
</p>
<Link
href="/"
className={cn(
buttonVariants(),
"bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold rounded-xl h-10 px-5 gap-2 shadow-md shadow-[#1B2CC1]/20"
)}
>
Jelajahi Open PO <ArrowRight className="w-4 h-4" />
</Link>
</div>
) : (
<div className="flex flex-col h-full space-y-4">
<div className="flex flex-col space-y-4 max-h-[calc(100vh-260px)] overflow-y-auto pr-2 scrollbar-thin">
{paginatedPurchases.map((sub) => {
const date = new Date(sub.order.date)
const isOrderOpen = sub.order.status === 'OPEN'
return (
<div
key={sub.id}
className="flex flex-col md:flex-row h-full md:h-auto rounded-2xl border border-slate-200/90 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm hover:shadow-md hover:border-[#1B2CC1]/40 transition-all overflow-hidden items-start md:items-stretch"
>
{/* Left: Info */}
<div className="flex-1 p-5 md:border-r border-slate-100 dark:border-slate-800/80 flex flex-col justify-center">
<div className="flex justify-between items-start gap-4 mb-3">
<h3 className="text-xl font-bold text-slate-900 dark:text-white line-clamp-2 flex-1 min-w-0 leading-snug">
{sub.order.title}
</h3>
<div className="flex flex-col sm:flex-row items-end sm:items-center gap-2 shrink-0">
<span className="text-[11px] text-slate-400 font-semibold bg-slate-100/80 dark:bg-slate-800 px-2.5 py-1 rounded-full whitespace-nowrap">
{format(date, 'EEEE, dd MMM yyyy', { locale: idLocale })}
</span>
<span className={cn(
"inline-flex items-center px-2.5 py-0.5 rounded-full text-[11px] font-black shrink-0",
sub.order.status === 'OPEN'
? "bg-emerald-50 text-emerald-700 border border-emerald-200"
: sub.order.status === 'CLOSE'
? "bg-rose-50 text-rose-700 border border-rose-200"
: "bg-slate-100 text-slate-700 border border-slate-200"
)}>
PO {sub.order.status}
</span>
</div>
</div>
<div className="flex items-center gap-2.5 pt-3 border-t border-slate-100 dark:border-slate-800/60">
{sub.order.creator.photo ? (
<img
src={sub.order.creator.photo}
alt={sub.order.creator.name}
className="w-7 h-7 rounded-full object-cover ring-2 ring-slate-100 dark:ring-slate-800 shrink-0"
/>
) : (
<div className="w-7 h-7 rounded-full bg-[#1B2CC1]/10 dark:bg-blue-950/40 text-[#1B2CC1] dark:text-blue-300 flex items-center justify-center font-bold text-xs shrink-0 ring-2 ring-slate-100 dark:ring-slate-800">
{sub.order.creator.name.charAt(0).toUpperCase()}
</div>
)}
<div className="flex flex-col min-w-0">
<span className="text-[10px] text-slate-400 font-medium leading-tight">Pembuat PO</span>
<span className="text-xs font-bold text-slate-800 dark:text-slate-200 truncate leading-tight">
{sub.order.creator.name}
</span>
</div>
</div>
</div>
{/* Middle: Items List */}
<div className="w-full md:w-64 p-5 md:border-r border-slate-100 dark:border-slate-800/80 flex flex-col justify-center">
<div className="flex items-center justify-between mb-2 border-t md:border-t-0 pt-4 md:pt-0 border-slate-100 dark:border-slate-800/60">
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-400 block">
Pesanan Anda ({sub.items.reduce((acc: number, curr: any) => acc + curr.qty, 0)} item)
</span>
{isOrderOpen && (
<span className="text-[10px] font-bold text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded-md">
Bisa Diubah
</span>
)}
</div>
<div className="bg-slate-50 dark:bg-slate-800/40 p-3.5 rounded-xl border border-slate-200/70 dark:border-slate-800 max-h-24 md:max-h-32 overflow-y-auto scrollbar-thin">
<ul className="space-y-1.5 text-xs">
{sub.items.map((item: any) => (
<li key={item.id} className="flex justify-between items-start font-medium gap-2">
<span className="text-slate-700 dark:text-slate-300 mr-1 leading-snug">
{item.name} {item.is_custom && <span className="text-[10px] font-bold text-[#1B2CC1] bg-blue-50 dark:bg-blue-950 px-1 py-0.5 rounded ml-1 whitespace-nowrap">Custom</span>}
</span>
<span className="font-black text-slate-900 dark:text-white shrink-0 mt-0.5">{item.qty}x</span>
</li>
))}
</ul>
</div>
</div>
{/* Right: Actions & Billing */}
<div className="w-full md:w-56 p-5 bg-slate-50/60 dark:bg-slate-800/40 flex flex-col justify-center gap-3 mt-auto md:mt-0">
<div className="flex flex-col gap-1 mb-1">
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-400 block">
Total Tagihan
</span>
<span className={cn(
"text-[9px] font-bold px-1.5 py-0.5 rounded-md text-center leading-tight border whitespace-nowrap",
sub.payment_status === 'LUNAS'
? "text-emerald-700 bg-emerald-50 border-emerald-200"
: "text-rose-700 bg-rose-50 border-rose-200"
)}>
{sub.payment_status === 'LUNAS' ? 'LUNAS' : 'HARAP BAYAR'}
</span>
</div>
<div className="text-lg font-black text-[#1B2CC1] dark:text-blue-400 leading-none mt-1">
{sub.bill ? formatRupiah(sub.bill) : <span className="text-slate-400 italic text-[13px] font-normal">Belum Dihitung</span>}
</div>
</div>
{/* Update Order Action Button */}
<div className="w-full mt-1">
{isOrderOpen ? (
<Button
onClick={() => setEditingPurchase(sub)}
className="w-full h-10 rounded-xl bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold text-xs shadow-sm gap-1.5"
>
<Pencil className="w-3.5 h-3.5" /> Ubah Titipan
</Button>
) : (
<div className="flex items-center justify-center gap-1.5 py-2 text-[11px] text-slate-400 font-medium bg-slate-100/70 dark:bg-slate-800/40 rounded-xl">
<Lock className="w-3 h-3" />
<span className="text-center leading-tight">PO ditutup<br/>(pesanan terkunci)</span>
</div>
)}
</div>
</div>
</div>
)
})}
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-6 mt-4 border-t border-slate-200 dark:border-slate-800">
<span className="text-xs text-slate-500 font-medium">
Menampilkan <span className="font-bold text-slate-900 dark:text-white">{(currentPage - 1) * ITEMS_PER_PAGE + 1}</span> hingga <span className="font-bold text-slate-900 dark:text-white">{Math.min(currentPage * ITEMS_PER_PAGE, filteredPurchases.length)}</span> dari <span className="font-bold text-slate-900 dark:text-white">{filteredPurchases.length}</span> titipan
</span>
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className="h-8 w-8 p-0 rounded-lg"
>
<ChevronLeft className="w-4 h-4" />
</Button>
<div className="flex items-center gap-1 px-2">
{Array.from({ length: totalPages }, (_, i) => i + 1).map(page => (
<Button
key={page}
variant="ghost"
size="sm"
onClick={() => setCurrentPage(page)}
className={cn(
"h-8 w-8 p-0 rounded-lg text-xs font-bold transition-all",
currentPage === page
? "bg-[#1B2CC1] text-white hover:bg-[#15229E] hover:text-white"
: "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
)}
>
{page}
</Button>
))}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
className="h-8 w-8 p-0 rounded-lg"
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</div>
)}
</div>
)
}
function EditPurchaseModal({
order,
userId,
onClose,
onSuccess
}: {
order: any
userId: string
onClose: () => void
onSuccess: () => void
}) {
const [items, setItems] = useState<Record<string, { selected: boolean, qty: number }>>({})
const [customItems, setCustomItems] = useState<Array<{ id: string, name: string, qty: number }>>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
loadExisting()
}, [order.id, userId])
const loadExisting = async () => {
setLoading(true)
const sub = await getUserSubmission(order.id, userId)
const initialItems: Record<string, { selected: boolean, qty: number }> = {}
const initialCustoms: any[] = []
order.available_items?.forEach((ai: any) => {
initialItems[ai.id] = { selected: false, qty: 0 }
})
if (sub) {
sub.items.forEach((item: any) => {
if (!item.is_custom) {
const stdItem = order.available_items?.find((ai: any) => ai.name === item.name)
if (stdItem) {
initialItems[stdItem.id] = { selected: true, qty: item.qty }
}
} else {
initialCustoms.push({ id: Math.random().toString(), name: item.name, qty: item.qty })
}
})
}
setItems(initialItems)
setCustomItems(initialCustoms)
setLoading(false)
}
const handleToggle = (itemId: string, checked: boolean) => {
setItems(prev => ({
...prev,
[itemId]: { selected: checked, qty: checked ? 1 : 0 }
}))
}
const handleQty = (itemId: string, qty: number) => {
if (qty < 1) {
handleToggle(itemId, false)
return
}
setItems(prev => ({
...prev,
[itemId]: { selected: true, qty }
}))
}
const addCustomItem = () => {
setCustomItems([...customItems, { id: Math.random().toString(), name: '', qty: 1 }])
}
const updateCustomItem = (id: string, field: 'name' | 'qty', value: any) => {
setCustomItems(customItems.map(c => c.id === id ? { ...c, [field]: value } : c))
}
const removeCustomItem = (id: string) => {
setCustomItems(customItems.filter(c => c.id !== id))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setSaving(true)
const payloadItems: any[] = []
order.available_items?.forEach((ai: any) => {
const state = items[ai.id]
if (state?.selected && state.qty > 0) {
payloadItems.push({ name: ai.name, qty: state.qty, is_custom: false })
}
})
customItems.forEach(ci => {
if (ci.name.trim() && ci.qty > 0) {
payloadItems.push({ name: ci.name.trim(), qty: ci.qty, is_custom: true })
}
})
if (payloadItems.length === 0) {
setError('Harap pilih minimal 1 item atau tambahkan item kustom.')
setSaving(false)
return
}
const res = await submitOrder({
order_id: order.id,
user_id: userId,
items: payloadItems
})
if (res.success) {
onSuccess()
} else {
setError(res.error || 'Gagal memperbarui pesanan.')
}
setSaving(false)
}
return (
<DialogContent
closeClassName="text-white/80 hover:text-slate-900 hover:bg-white/90"
className="sm:max-w-[520px] p-0 overflow-hidden rounded-3xl border-slate-200/90 dark:border-slate-800 shadow-2xl max-h-[90vh] flex flex-col"
>
<div className="bg-gradient-to-br from-[#1B2CC1] to-[#121E85] p-6 text-white">
<span className="text-[11px] font-bold uppercase tracking-wider text-blue-200">Ubah Pesanan Titipan</span>
<DialogTitle className="text-2xl font-black tracking-tight text-white mt-1">
{order.title}
</DialogTitle>
<p className="text-xs text-blue-100 mt-1">
Sesuaikan menu atau jumlah item titipan Anda selama status PO masih OPEN.
</p>
</div>
{loading ? (
<div className="flex flex-col items-center justify-center p-12 gap-2">
<Loader2 className="w-7 h-7 animate-spin text-[#1B2CC1]" />
<span className="text-xs text-slate-500 font-medium">Memuat data pesanan...</span>
</div>
) : (
<form onSubmit={handleSubmit} className="p-6 overflow-y-auto space-y-6 flex-1 bg-white dark:bg-slate-900">
{/* Standard Items */}
<div className="space-y-3">
<Label className="text-xs font-bold uppercase tracking-wider text-slate-400">
Daftar Menu Tersedia
</Label>
<div className="space-y-2">
{order.available_items?.map((item: any) => {
const isSelected = items[item.id]?.selected || false
const qty = items[item.id]?.qty || 0
return (
<div
key={item.id}
className={cn(
"flex items-center justify-between p-3.5 rounded-xl border transition-all",
isSelected
? "border-[#1B2CC1] bg-[#1B2CC1]/5 dark:bg-blue-950/20 shadow-sm"
: "border-slate-200/80 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50"
)}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<Checkbox
id={`purchase-item-${item.id}`}
checked={isSelected}
onCheckedChange={(c) => handleToggle(item.id, c as boolean)}
className="rounded-lg data-[state=checked]:bg-[#1B2CC1] data-[state=checked]:border-[#1B2CC1]"
/>
<Label htmlFor={`purchase-item-${item.id}`} className="text-sm font-bold text-slate-800 dark:text-slate-200 cursor-pointer truncate">
{item.name}
</Label>
</div>
{isSelected && (
<div className="flex items-center gap-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg p-0.5 shadow-sm">
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-500 hover:text-slate-800"
onClick={() => handleQty(item.id, qty - 1)}
>
<MinusCircle className="h-4 w-4" />
</Button>
<span className="w-7 text-center text-xs font-extrabold text-[#1B2CC1] dark:text-blue-400">{qty}</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-500 hover:text-slate-800"
onClick={() => handleQty(item.id, qty + 1)}
>
<PlusCircle className="h-4 w-4" />
</Button>
</div>
)}
</div>
)
})}
{(!order.available_items || order.available_items.length === 0) && (
<p className="text-xs text-slate-400 text-center py-3 bg-slate-50 dark:bg-slate-800/40 rounded-xl border border-dashed border-slate-200 dark:border-slate-800">
Tidak ada menu standar. Silakan gunakan item kustom.
</p>
)}
</div>
</div>
{/* Custom Items */}
{order.allow_custom && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs font-bold uppercase tracking-wider text-slate-400">
Item Tambahan (Kustom)
</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={addCustomItem}
className="h-8 text-xs font-bold rounded-lg border-dashed border-[#1B2CC1]/40 text-[#1B2CC1] hover:bg-[#1B2CC1]/10 gap-1.5"
>
<PlusCircle className="h-3.5 w-3.5" /> Tambah Kustom
</Button>
</div>
{customItems.length > 0 ? (
<div className="space-y-2.5">
{customItems.map((ci, idx) => (
<div key={ci.id} className="flex gap-2 items-center p-3 rounded-xl border border-slate-200/90 dark:border-slate-800 bg-slate-50/70 dark:bg-slate-800/40">
<span className="text-xs font-bold text-slate-400 w-4">{idx + 1}.</span>
<Input
placeholder="Nama Menu / Catatan Khusus"
value={ci.name}
onChange={(e) => updateCustomItem(ci.id, 'name', e.target.value)}
className="flex-1 h-9 rounded-lg bg-white dark:bg-slate-900 text-xs font-medium"
/>
<Input
type="number"
min="1"
value={ci.qty}
onChange={(e) => updateCustomItem(ci.id, 'qty', parseInt(e.target.value) || 1)}
className="w-16 h-9 rounded-lg bg-white dark:bg-slate-900 text-center font-bold text-xs"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeCustomItem(ci.id)}
className="h-8 w-8 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-lg"
>
<MinusCircle className="h-4 w-4" />
</Button>
</div>
))}
</div>
) : (
<div
onClick={addCustomItem}
className="p-4 border-2 border-dashed border-slate-200 dark:border-slate-800 hover:border-[#1B2CC1]/50 rounded-2xl text-center bg-slate-50/50 dark:bg-slate-800/30 cursor-pointer transition-all group"
>
<PlusCircle className="w-5 h-5 text-slate-400 group-hover:text-[#1B2CC1] mx-auto mb-1 transition-colors" />
<p className="text-xs font-bold text-slate-600 dark:text-slate-300">Klik untuk Tambah Item Custom</p>
</div>
)}
</div>
)}
{error && (
<div className="p-3 bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900 rounded-xl text-xs text-red-600 dark:text-red-400 font-semibold">
{error}
</div>
)}
<div className="flex gap-2.5 pt-2">
<Button
type="button"
variant="outline"
onClick={onClose}
className="w-1/3 h-11 rounded-xl font-bold text-xs"
>
Batal
</Button>
<Button
type="submit"
disabled={saving}
className="flex-1 h-11 rounded-xl bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold text-xs shadow-md shadow-[#1B2CC1]/25"
>
{saving ? 'Menyimpan Perubahan...' : 'Simpan Perubahan Titipan'}
</Button>
</div>
</form>
)}
</DialogContent>
)
}
+93 -60
View File
@@ -1,69 +1,102 @@
import Image from "next/image";
import { getAvailableOrders } from './actions'
import { OrderCard } from '@/components/OrderCard'
import { Sparkles, Plus, Store } from 'lucide-react'
import Link from 'next/link'
import { buttonVariants } from '@/components/ui/button'
import { cn } from '@/lib/utils'
export const dynamic = 'force-dynamic'
export default async function Dashboard() {
const orders = await getAvailableOrders()
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert h-5 w-[100px]"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the{" "}
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
page.tsx
</code>{" "}
file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
<div className="space-y-8 animate-in fade-in duration-500">
{/* Banner / Hero Section */}
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-r from-[#1B2CC1] via-[#2135E0] to-[#121E85] p-6 sm:p-8 text-white shadow-xl shadow-[#1B2CC1]/15">
<div className="absolute right-0 top-0 -mt-10 -mr-10 h-64 w-64 rounded-full bg-white/10 blur-3xl pointer-events-none" />
<div className="relative z-10 max-w-2xl space-y-3">
<div className="inline-flex items-center gap-2 rounded-full bg-white/15 px-3 py-1 text-xs font-bold text-blue-100 backdrop-blur-md border border-white/20">
<Sparkles className="h-3.5 w-3.5" />
<span>Sistem Titip & Jastip Cepat</span>
</div>
<h2 className="text-2xl sm:text-3xl font-black tracking-tight leading-tight">
Titip Makanan & Minuman Bareng Teman Kantor
</h2>
<p className="text-sm text-blue-100/90 leading-relaxed max-w-xl">
Pilih PO yang sedang buka hari ini, tentukan menu favoritmu, dan biarkan pembuat PO mengurus tagihan secara praktis.
</p>
<div className="pt-2 flex flex-wrap gap-3">
<Link
href="/my-orders"
className={cn(
buttonVariants(),
"rounded-xl bg-white text-[#1B2CC1] hover:bg-blue-50 font-bold shadow-md h-10 px-5 gap-2"
)}
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
<Plus className="w-4 h-4" />
Buka Jasa PO Baru
</Link>
<Link
href="/my-purchases"
className={cn(
buttonVariants({ variant: "outline" }),
"rounded-xl bg-white/10 hover:bg-white/20 text-white border-white/20 font-semibold h-10 px-4"
)}
>
Learning
</a>{" "}
center.
Lihat Titipan Saya
</Link>
</div>
</div>
</div>
{/* Main Grid Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full bg-[#1B2CC1] animate-pulse" />
<h2 className="text-xl font-black text-slate-900 dark:text-white tracking-tight">
Daftar PO Terbuka Hari Ini
</h2>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Pesanan dengan status OPEN yang bisa Anda ikuti hari ini.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert h-[14px] w-4"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={14}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
<span className="text-xs font-bold text-[#1B2CC1] bg-[#1B2CC1]/10 px-3 py-1.5 rounded-xl">
Total: {orders.length} PO Aktif
</span>
</div>
</main>
{orders.length === 0 ? (
<div className="flex flex-col items-center justify-center p-12 sm:p-16 text-center rounded-3xl border-2 border-dashed border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm">
<div className="w-16 h-16 bg-[#1B2CC1]/10 rounded-2xl flex items-center justify-center mb-4 text-[#1B2CC1]">
<Store className="w-8 h-8" />
</div>
);
<h3 className="text-lg font-bold text-slate-800 dark:text-slate-200 mb-1">Belum Ada PO yang Dibuka Hari Ini</h3>
<p className="text-xs text-slate-500 max-w-md mb-6 leading-relaxed">
Mau jajan atau beli sesuatu? Jadilah orang pertama yang membuka jasa titip pesanan untuk teman-teman Anda!
</p>
<Link
href="/my-orders"
className={cn(
buttonVariants(),
"bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold rounded-xl shadow-md shadow-[#1B2CC1]/25 h-10 px-5 gap-2"
)}
>
<Plus className="w-4 h-4" /> Buka PO Sekarang
</Link>
</div>
) : (
<div className="flex flex-col h-full space-y-4">
<div className="flex flex-col space-y-4 max-h-[calc(100vh-260px)] overflow-y-auto pr-2 scrollbar-thin">
{orders.map((order: any) => (
<OrderCard key={order.id} order={order} />
))}
</div>
</div>
)}
</div>
)
}
+222
View File
@@ -0,0 +1,222 @@
'use client'
import { useEffect, useState } from 'react'
import { checkUser, updateProfile } from '@/app/actions'
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Loader2, UserCircle, Save, CheckCircle, Upload, ShieldCheck, Sparkles, Image as ImageIcon } from 'lucide-react'
export default function ProfilePage() {
const [userId, setUserId] = useState<string | null>(null)
const [name, setName] = useState('')
const [photo, setPhoto] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
useEffect(() => {
const id = localStorage.getItem('user_id')
if (id) {
setUserId(id)
loadProfile(id)
}
}, [])
const loadProfile = async (id: string) => {
setLoading(true)
const user = await checkUser(id)
if (user) {
setName(user.name)
setPhoto(user.photo || '')
}
setLoading(false)
}
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) {
setError('Nama wajib diisi')
return
}
setError('')
setSuccess(false)
setSaving(true)
const res = await updateProfile(userId!, name.trim(), photo.trim() || undefined)
if (res.success) {
setSuccess(true)
setTimeout(() => setSuccess(false), 3000)
} else {
setError(res.error || 'Terjadi kesalahan')
}
setSaving(false)
}
if (loading) {
return (
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-3">
<Loader2 className="w-8 h-8 animate-spin text-[#1B2CC1]" />
<span className="text-xs text-slate-500 font-semibold">Memuat profil...</span>
</div>
)
}
return (
<div className="space-y-6 animate-in fade-in duration-500 pb-12 max-w-5xl">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left 2 Cols: Main Profile Form */}
<div className="lg:col-span-2 space-y-6">
<Card className="rounded-3xl border border-slate-200/90 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm overflow-hidden">
<div className="p-6 border-b border-slate-100 dark:border-slate-800 flex items-center justify-between">
<div>
<h2 className="text-lg font-black text-slate-900 dark:text-white">
Informasi Akun
</h2>
<p className="text-xs text-slate-500 mt-0.5">
Ubah nama tampilan dan foto avatar yang terlihat oleh teman-teman.
</p>
</div>
<span className="text-[11px] font-bold text-[#1B2CC1] bg-[#1B2CC1]/10 px-2.5 py-1 rounded-full">
Stateless Auth
</span>
</div>
<form onSubmit={handleSave}>
<CardContent className="p-6 space-y-6">
{/* Photo Preview & Dashed Container */}
<div className="space-y-2">
<Label className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300">
Foto Profil
</Label>
<div className="flex flex-col sm:flex-row items-center gap-5 p-5 rounded-2xl border-2 border-dashed border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/30">
{photo ? (
<img
src={photo}
alt={name}
className="w-20 h-20 rounded-2xl object-cover ring-4 ring-white dark:ring-slate-700 shadow-md shrink-0"
/>
) : (
<div className="w-20 h-20 rounded-2xl bg-[#1B2CC1]/10 text-[#1B2CC1] flex items-center justify-center font-bold text-2xl ring-4 ring-white dark:ring-slate-700 shadow-sm shrink-0">
{name ? name.charAt(0).toUpperCase() : <UserCircle className="w-10 h-10" />}
</div>
)}
<div className="space-y-1.5 flex-1 w-full text-center sm:text-left">
<p className="text-xs font-bold text-slate-800 dark:text-slate-200">
Pratinjau Avatar
</p>
<p className="text-[11px] text-slate-400">
Masukkan tautan URL foto gambar di bawah untuk memperbarui gambar profil.
</p>
</div>
</div>
</div>
{/* Form Inputs */}
<div className="space-y-1.5">
<Label htmlFor="prof-name" className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300">
Nama Layar (Wajib Unik) <span className="text-red-500">*</span>
</Label>
<Input
id="prof-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Contoh: Budi Santoso"
className="h-11 rounded-xl border-slate-200 dark:border-slate-800 focus-visible:ring-4 focus-visible:ring-[#1B2CC1]/15 focus-visible:border-[#1B2CC1] font-semibold"
/>
<p className="text-[11px] text-slate-400">Nama ini akan tercantum di setiap PO yang Anda buat atau ikuti.</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="prof-photo" className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300">
URL Foto Profil
</Label>
<Input
id="prof-photo"
value={photo}
onChange={(e) => setPhoto(e.target.value)}
placeholder="https://images.unsplash.com/photo-..."
className="h-11 rounded-xl border-slate-200 dark:border-slate-800 focus-visible:ring-4 focus-visible:ring-[#1B2CC1]/15 focus-visible:border-[#1B2CC1] text-xs font-mono"
/>
</div>
{error && (
<div className="p-3.5 bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900 rounded-xl text-xs text-red-600 dark:text-red-400 font-semibold">
{error}
</div>
)}
{success && (
<div className="p-3.5 bg-emerald-50 dark:bg-emerald-950/30 border border-emerald-200 dark:border-emerald-900 rounded-xl text-xs text-emerald-700 dark:text-emerald-400 font-bold flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-emerald-600" />
<span>Profil Anda berhasil diperbarui!</span>
</div>
)}
</CardContent>
{/* Card Footer with Dedicated Container & Padding */}
<div className="p-6 bg-slate-50/60 dark:bg-slate-800/40 border-t border-slate-100 dark:border-slate-800 flex justify-end items-center">
<Button
type="submit"
disabled={saving}
className="w-full sm:w-auto h-11 px-6 rounded-xl bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold shadow-md shadow-[#1B2CC1]/20 gap-2 cursor-pointer"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
<span>{saving ? 'Menyimpan...' : 'Simpan Perubahan'}</span>
</Button>
</div>
</form>
</Card>
</div>
{/* Right 1 Col: Device & ID Details */}
<div className="space-y-6">
<Card className="rounded-3xl border border-slate-200/90 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm p-6 space-y-4">
<div className="flex items-center gap-2 text-slate-800 dark:text-slate-200 font-bold text-sm">
<ShieldCheck className="w-4 h-4 text-[#1B2CC1]" />
<span>Identitas Stateless</span>
</div>
<p className="text-xs text-slate-500 leading-relaxed">
Akun Anda tersimpan secara lokal pada peramban ini menggunakan Device ID yang unik di bawah ini.
</p>
<div className="space-y-1.5 pt-2 border-t border-slate-100 dark:border-slate-800">
<div className="flex items-center justify-between">
<span className="text-[10px] uppercase font-bold text-slate-400 block">Unique User UUID</span>
<button
type="button"
onClick={() => {
if (userId) {
navigator.clipboard.writeText(userId)
}
}}
className="text-[10px] font-bold text-[#1B2CC1] hover:underline"
>
Salin ID
</button>
</div>
<div className="p-3 rounded-xl bg-slate-50 dark:bg-slate-800/60 border border-slate-200/80 dark:border-slate-700 text-xs font-mono text-slate-700 dark:text-slate-300 break-all select-all font-semibold">
{userId}
</div>
</div>
<div className="p-4 rounded-2xl bg-blue-50/60 dark:bg-blue-950/30 border border-blue-100 dark:border-blue-900/40 text-xs text-[#1B2CC1] dark:text-blue-300 space-y-1">
<div className="flex items-center gap-1.5 font-bold">
<Sparkles className="w-3.5 h-3.5" />
<span>Tips Penggunaan</span>
</div>
<p className="text-[11px] opacity-90 leading-tight">
Jangan bersihkan data peramban (localStorage) jika Anda ingin tetap login dengan ID yang sama.
</p>
</div>
</Card>
</div>
</div>
</div>
)
}
+24
View File
@@ -0,0 +1,24 @@
'use client'
import { useState } from 'react'
import { Sidebar } from './Sidebar'
import { Header } from './Header'
export function AppLayout({ children }: { children: React.ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(false)
return (
<div className="flex min-h-screen bg-[#F4F6FB] dark:bg-[#0B0F19] text-slate-900 dark:text-slate-100 font-sans">
{/* Sidebar */}
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
{/* Main Content Area */}
<div className="flex-1 flex flex-col min-w-0">
<Header onMenuClick={() => setSidebarOpen(true)} />
<main className="flex-1 p-4 sm:p-6 lg:p-8 max-w-7xl w-full mx-auto">
{children}
</main>
</div>
</div>
)
}
+157
View File
@@ -0,0 +1,157 @@
'use client'
import { useEffect, useState } from 'react'
import { v4 as uuidv4 } from 'uuid'
import { checkUser, registerUser } from '@/app/actions'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Loader2, Sparkles, User, Image as ImageIcon } from 'lucide-react'
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [isVerified, setIsVerified] = useState(false)
const [showModal, setShowModal] = useState(false)
const [userId, setUserId] = useState<string | null>(null)
const [name, setName] = useState('')
const [photo, setPhoto] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
useEffect(() => {
async function initAuth() {
let id = localStorage.getItem('user_id')
if (!id) {
id = uuidv4()
localStorage.setItem('user_id', id)
}
setUserId(id)
const user = await checkUser(id)
if (user) {
setIsVerified(true)
} else {
setShowModal(true)
}
setLoading(false)
}
initAuth()
}, [])
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) {
setError('Nama wajib diisi')
return
}
setError('')
setSaving(true)
const res = await registerUser(userId!, name.trim(), photo.trim() || undefined)
if (res.success) {
setShowModal(false)
setIsVerified(true)
} else {
setError(res.error || 'Terjadi kesalahan')
}
setSaving(false)
}
const handleOpenChange = (open: boolean) => {
if (!open && !isVerified) {
setShowModal(true)
}
}
if (loading) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-[#F4F6FB] dark:bg-[#0B0F19] gap-4">
<div className="w-12 h-12 rounded-2xl bg-[#1B2CC1] flex items-center justify-center text-white shadow-xl shadow-[#1B2CC1]/30 animate-pulse">
<Sparkles className="w-6 h-6" />
</div>
<div className="flex items-center gap-2 text-sm font-semibold text-slate-500">
<Loader2 className="h-4 w-4 animate-spin text-[#1B2CC1]" />
<span>Memuat sesi Anda...</span>
</div>
</div>
)
}
return (
<>
{isVerified && children}
<Dialog open={showModal} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[440px] p-0 overflow-hidden rounded-3xl border-slate-200/90 dark:border-slate-800 shadow-2xl [&>button]:hidden">
<div className="bg-gradient-to-br from-[#1B2CC1] to-[#121E85] p-6 text-white text-center relative overflow-hidden">
<div className="absolute -right-8 -bottom-8 w-32 h-32 bg-white/10 rounded-full blur-2xl pointer-events-none" />
<div className="w-14 h-14 rounded-2xl bg-white/15 backdrop-blur-md flex items-center justify-center mx-auto mb-3 text-white border border-white/20 shadow-inner">
<Sparkles className="w-7 h-7" />
</div>
<DialogTitle className="text-2xl font-black tracking-tight text-white">
Selamat Datang di TitipIn
</DialogTitle>
<DialogDescription className="text-blue-100/90 text-xs mt-1.5 max-w-xs mx-auto">
Daftarkan nama Anda untuk mulai membuka atau menitip pesanan bersama teman.
</DialogDescription>
</div>
<form onSubmit={handleRegister} className="p-6 space-y-4 bg-white dark:bg-slate-900">
<div className="space-y-1.5">
<Label htmlFor="reg-name" className="text-xs font-bold text-slate-700 dark:text-slate-300">
Nama Tampilan <span className="text-red-500">*</span>
</Label>
<div className="relative">
<Input
id="reg-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Contoh: Budi Santoso"
className="rounded-xl h-11 border-slate-200 dark:border-slate-800 focus-visible:ring-4 focus-visible:ring-[#1B2CC1]/15 focus-visible:border-[#1B2CC1]"
autoComplete="off"
/>
</div>
<p className="text-[11px] text-slate-400">Nama harus unik agar pembuat PO mudah mengenali Anda.</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="reg-photo" className="text-xs font-bold text-slate-700 dark:text-slate-300">
URL Foto Profil (Opsional)
</Label>
<Input
id="reg-photo"
value={photo}
onChange={(e) => setPhoto(e.target.value)}
placeholder="https://images.unsplash.com/..."
className="rounded-xl h-11 border-slate-200 dark:border-slate-800 focus-visible:ring-4 focus-visible:ring-[#1B2CC1]/15 focus-visible:border-[#1B2CC1]"
autoComplete="off"
/>
</div>
{error && (
<div className="p-3 bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-900/60 rounded-xl text-xs text-red-600 dark:text-red-400 font-semibold">
{error}
</div>
)}
<Button
type="submit"
disabled={saving}
className="w-full h-11 rounded-xl bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold shadow-md shadow-[#1B2CC1]/25 transition-all mt-2"
>
{saving ? (
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
<span>Menyimpan Profil...</span>
</div>
) : (
'Mulai Gunakan TitipIn'
)}
</Button>
</form>
</DialogContent>
</Dialog>
</>
)
}
+114
View File
@@ -0,0 +1,114 @@
'use client'
import { useEffect, useState } from 'react'
import { usePathname } from 'next/navigation'
import { Menu, Calendar, Store, ClipboardList, Package, User, FileText } from 'lucide-react'
import { buttonVariants } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import Link from 'next/link'
import { format } from 'date-fns'
import { id as idLocale } from 'date-fns/locale'
export function Header({ onMenuClick }: { onMenuClick: () => void }) {
const pathname = usePathname()
const [todayStr, setTodayStr] = useState('')
useEffect(() => {
setTodayStr(format(new Date(), 'EEEE, dd MMMM yyyy', { locale: idLocale }))
}, [])
const getPageInfo = () => {
switch (pathname) {
case '/':
return {
title: 'Open Order Hari Ini',
subtitle: 'Daftar pesanan aktif yang siap kamu titip.',
badge: 'Live PO',
Icon: Store
}
case '/my-orders':
return {
title: 'Jasa Order Saya',
subtitle: 'Kelola PO yang Anda buka untuk teman-teman.',
badge: 'Manajemen PO',
Icon: ClipboardList
}
case '/my-purchases':
return {
title: 'Pesanan Saya',
subtitle: 'Pantau barang yang Anda titip beserta status tagihannya.',
badge: 'Riwayat Titipan',
Icon: Package
}
case '/profile':
return {
title: 'Pengaturan Profil',
subtitle: 'Kelola identitas dan preferensi akun Anda.',
badge: 'Akun',
Icon: User
}
default:
if (pathname.startsWith('/my-orders/')) {
return {
title: 'Detail & Rekap Order',
subtitle: 'Rincian pesanan, tagihan pemesan, dan ringkasan belanja.',
badge: 'Detail PO',
Icon: FileText
}
}
return {
title: 'TitipIn Dashboard',
subtitle: 'Sistem Titip Pesanan Bersama',
badge: 'Dashboard',
Icon: Store
}
}
}
const { title, subtitle, badge, Icon } = getPageInfo()
return (
<header className="sticky top-0 z-30 bg-white/90 dark:bg-slate-900/90 backdrop-blur-md border-b border-slate-200/80 dark:border-slate-800 px-6 py-4">
<div className="flex items-center justify-between gap-4">
{/* Left: Mobile hamburger & Page Title */}
<div className="flex items-center gap-3">
<button
onClick={onMenuClick}
className="lg:hidden p-2 rounded-xl border border-slate-200 dark:border-slate-800 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
<Menu className="w-5 h-5" />
</button>
<div className="flex items-center gap-3 sm:gap-4">
<div className="hidden sm:flex items-center justify-center w-11 h-11 md:w-12 md:h-12 rounded-xl border border-blue-100 dark:border-blue-900/50 bg-gradient-to-br from-blue-50 to-[#1B2CC1]/10 dark:from-[#1B2CC1]/20 dark:to-[#121E85]/20 shadow-inner shrink-0">
<Icon className="w-5 h-5 md:w-6 md:h-6 text-[#1B2CC1] dark:text-blue-400" strokeWidth={2.5} />
</div>
<div>
<div className="flex items-center gap-2">
<h1 className="text-xl sm:text-2xl font-black tracking-tight text-slate-900 dark:text-white">
{title}
</h1>
<span className="hidden sm:inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-bold bg-[#1B2CC1]/10 text-[#1B2CC1] dark:bg-blue-900/40 dark:text-blue-300">
{badge}
</span>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5 hidden sm:block">
{subtitle}
</p>
</div>
</div>
</div>
{/* Right: Date info & Quick Action */}
<div className="flex items-center gap-3">
{todayStr && (
<div className="hidden md:flex items-center gap-2 px-3 py-1.5 rounded-xl bg-slate-100/70 dark:bg-slate-800/60 border border-slate-200/60 dark:border-slate-800 text-xs font-medium text-slate-600 dark:text-slate-300 animate-in fade-in">
<Calendar className="w-3.5 h-3.5 text-[#1B2CC1]" />
<span>{todayStr}</span>
</div>
)}
</div>
</div>
</header>
)
}
+63
View File
@@ -0,0 +1,63 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { cn } from '@/lib/utils'
import { PackageOpen } from 'lucide-react'
const navItems = [
{ name: 'Open Order', href: '/' },
{ name: 'Jasa Order Saya', href: '/my-orders' },
{ name: 'Pesanan Saya', href: '/my-purchases' },
{ name: 'Profile', href: '/profile' },
]
export function Navbar() {
const pathname = usePathname()
return (
<nav className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-16 items-center px-4 mx-auto max-w-5xl">
<div className="mr-8 flex items-center gap-2">
<PackageOpen className="h-6 w-6 text-primary" />
<Link href="/" className="font-bold text-xl tracking-tight text-primary">
TitipIn
</Link>
</div>
<div className="hidden md:flex flex-1 items-center justify-between text-sm font-medium">
<div className="flex gap-6">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
"transition-colors hover:text-foreground/80",
pathname === item.href ? "text-foreground" : "text-foreground/60"
)}
>
{item.name}
</Link>
))}
</div>
</div>
{/* Mobile Navigation */}
<div className="flex flex-1 items-center justify-end md:hidden overflow-hidden">
<div className="flex gap-4 overflow-x-auto text-sm font-medium pb-1 no-scrollbar w-full">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
"whitespace-nowrap transition-colors",
pathname === item.href ? "text-foreground" : "text-foreground/60"
)}
>
{item.name}
</Link>
))}
</div>
</div>
</div>
</nav>
)
}
+370
View File
@@ -0,0 +1,370 @@
'use client'
import { useState, useEffect } from 'react'
import { Button, buttonVariants } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { submitOrder, getUserSubmission } from '@/app/actions'
import { PlusCircle, MinusCircle, User, CheckCircle2, ShoppingBag, Sparkles, Users } from 'lucide-react'
import { format } from 'date-fns'
import { id as idLocale } from 'date-fns/locale'
export function OrderCard({ order }: { order: any }) {
const [open, setOpen] = useState(false)
return (
<div className="flex flex-col md:flex-row h-full md:h-auto rounded-2xl border border-slate-200/90 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm hover:shadow-xl hover:border-[#1B2CC1]/40 dark:hover:border-blue-500/40 transition-all duration-300 group overflow-hidden items-start md:items-stretch">
{/* Left: Info */}
<div className="flex-[1.2] p-5 md:border-r border-slate-100 dark:border-slate-800/80 flex flex-col justify-center min-w-0 w-full">
<div className="flex justify-between items-start gap-4">
<h3 className="text-lg font-bold text-slate-900 dark:text-white line-clamp-2 leading-snug flex-1 min-w-0 group-hover:text-[#1B2CC1] dark:group-hover:text-blue-400 transition-colors">
{order.title}
</h3>
<div className="flex flex-col sm:flex-row items-end sm:items-center gap-2 shrink-0">
<span className="text-[10px] sm:text-[11px] text-slate-500 font-semibold bg-slate-100/80 dark:bg-slate-800 px-2.5 py-1 rounded-full whitespace-nowrap">
{format(new Date(order.date), 'EEEE, dd MMM yyyy', { locale: idLocale })}
</span>
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-[10px] sm:text-[11px] font-black tracking-wide bg-emerald-50 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-400 border border-emerald-200/60 dark:border-emerald-900/50">
OPEN
</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-3 mt-3 pt-3 border-t border-slate-100 dark:border-slate-800/60">
<div className="flex items-center gap-2">
{order.creator.photo ? (
<img src={order.creator.photo} alt={order.creator.name} className="w-5 h-5 rounded-full object-cover ring-2 ring-slate-100 dark:ring-slate-800" />
) : (
<div className="w-5 h-5 rounded-full bg-[#1B2CC1]/10 dark:bg-blue-900/40 text-[#1B2CC1] dark:text-blue-300 flex items-center justify-center font-bold text-[10px]">
{order.creator.name.charAt(0).toUpperCase()}
</div>
)}
<span className="text-xs font-bold text-slate-700 dark:text-slate-300">{order.creator.name}</span>
</div>
<div className="w-1 h-1 rounded-full bg-slate-300 dark:bg-slate-600 hidden sm:block"></div>
<div className="flex items-center gap-1.5">
<Users className="w-3.5 h-3.5 text-[#1B2CC1]" />
<span className="text-xs font-bold text-slate-600 dark:text-slate-400">
{order.submissions.length} Orang Menitip
</span>
</div>
</div>
</div>
{/* Middle: Items List */}
<div className="w-full md:w-64 p-5 md:border-r border-slate-100 dark:border-slate-800/80 flex flex-col justify-center border-t md:border-t-0">
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-400 block">
Item Tersedia ({order.available_items.length})
</span>
{order.allow_custom && (
<div className="inline-flex items-center gap-1 text-[9px] font-semibold text-[#1B2CC1] bg-blue-50 dark:bg-blue-950/40 px-1.5 py-0.5 rounded-md">
<Sparkles className="w-3 h-3" /> Kustom
</div>
)}
</div>
<div className="bg-slate-50 dark:bg-slate-800/40 p-3 rounded-xl border border-slate-200/70 dark:border-slate-800 max-h-24 overflow-y-auto scrollbar-thin">
<ul className="space-y-1.5">
{order.available_items.map((item: any) => (
<li key={item.id} className="text-[11px] flex items-start gap-2 text-slate-700 dark:text-slate-300 font-medium">
<div className="w-3.5 h-3.5 rounded-full bg-[#1B2CC1]/10 text-[#1B2CC1] flex items-center justify-center shrink-0 mt-0.5">
<CheckCircle2 className="w-2.5 h-2.5" />
</div>
<span className="truncate leading-snug">{item.name}</span>
</li>
))}
{order.available_items.length === 0 && (
<li className="text-[10px] text-slate-400 italic">Hanya menerima item kustom.</li>
)}
</ul>
</div>
</div>
{/* Right: Actions */}
<div className="w-full md:w-48 p-5 bg-slate-50/60 dark:bg-slate-800/40 flex flex-col items-center justify-center gap-3">
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className={cn(
buttonVariants({ size: "sm" }),
"w-full h-9 rounded-xl bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold shadow-md shadow-[#1B2CC1]/20 transition-all hover:scale-[1.01] gap-1.5 cursor-pointer"
)}>
<ShoppingBag className="w-3.5 h-3.5" />
<span>Titip Sekarang</span>
</DialogTrigger>
<OrderFormModal order={order} onSuccess={() => setOpen(false)} />
</Dialog>
</div>
</div>
)
}
function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: () => void }) {
const [userId, setUserId] = useState<string>('')
const [items, setItems] = useState<Record<string, { selected: boolean, qty: number }>>({})
const [customItems, setCustomItems] = useState<Array<{ id: string, name: string, qty: number }>>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
const id = localStorage.getItem('user_id')
if (id) {
setUserId(id)
loadExistingSubmission(id)
}
}, [order.id])
const loadExistingSubmission = async (uid: string) => {
const sub = await getUserSubmission(order.id, uid)
if (sub) {
const newItems = { ...items }
const newCustoms: any[] = []
sub.items.forEach((item: any) => {
if (!item.is_custom) {
const stdItem = order.available_items.find((ai: any) => ai.name === item.name)
if (stdItem) {
newItems[stdItem.id] = { selected: true, qty: item.qty }
}
} else {
newCustoms.push({ id: Math.random().toString(), name: item.name, qty: item.qty })
}
})
setItems(newItems)
setCustomItems(newCustoms)
}
}
const handleStandardItemToggle = (itemId: string, checked: boolean) => {
setItems(prev => ({
...prev,
[itemId]: { selected: checked, qty: checked ? 1 : 0 }
}))
}
const handleStandardItemQty = (itemId: string, qty: number) => {
if (qty < 1) {
handleStandardItemToggle(itemId, false)
return
}
setItems(prev => ({
...prev,
[itemId]: { selected: true, qty }
}))
}
const addCustomItem = () => {
setCustomItems([...customItems, { id: Math.random().toString(), name: '', qty: 1 }])
}
const updateCustomItem = (id: string, field: 'name' | 'qty', value: any) => {
setCustomItems(customItems.map(c => c.id === id ? { ...c, [field]: value } : c))
}
const removeCustomItem = (id: string) => {
setCustomItems(customItems.filter(c => c.id !== id))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
const payloadItems: any[] = []
order.available_items.forEach((ai: any) => {
const state = items[ai.id]
if (state?.selected && state.qty > 0) {
payloadItems.push({ name: ai.name, qty: state.qty, is_custom: false })
}
})
customItems.forEach(ci => {
if (ci.name.trim() && ci.qty > 0) {
payloadItems.push({ name: ci.name.trim(), qty: ci.qty, is_custom: true })
}
})
if (payloadItems.length === 0) {
setError('Harap pilih minimal 1 item atau tambahkan item lainnya.')
setLoading(false)
return
}
const res = await submitOrder({
order_id: order.id,
user_id: userId,
items: payloadItems
})
if (res.success) {
onSuccess()
} else {
setError(res.error || 'Gagal menyimpan pesanan.')
}
setLoading(false)
}
return (
<DialogContent className="sm:max-w-[520px] p-0 overflow-hidden rounded-3xl border-slate-200/90 dark:border-slate-800 shadow-2xl max-h-[90vh] flex flex-col">
<div className="bg-gradient-to-br from-[#1B2CC1] to-[#121E85] p-6 text-white">
<span className="text-[11px] font-bold uppercase tracking-wider text-blue-200">Form Titipan</span>
<DialogTitle className="text-2xl font-black tracking-tight text-white mt-1">
{order.title}
</DialogTitle>
<p className="text-xs text-blue-100 mt-1">
Pilih menu yang tersedia di bawah atau tambahkan item khusus jika diizinkan.
</p>
</div>
<form onSubmit={handleSubmit} className="p-6 overflow-y-auto space-y-6 flex-1 bg-white dark:bg-slate-900">
{/* Standard Items */}
<div className="space-y-3">
<Label className="text-xs font-bold uppercase tracking-wider text-slate-400">
Daftar Menu Tersedia
</Label>
<div className="space-y-2">
{order.available_items.map((item: any) => {
const isSelected = items[item.id]?.selected || false
const qty = items[item.id]?.qty || 0
return (
<div
key={item.id}
className={cn(
"flex items-center justify-between p-3.5 rounded-xl border transition-all",
isSelected
? "border-[#1B2CC1] bg-[#1B2CC1]/5 dark:bg-blue-950/20 shadow-sm"
: "border-slate-200/80 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50"
)}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<Checkbox
id={`item-${item.id}`}
checked={isSelected}
onCheckedChange={(c) => handleStandardItemToggle(item.id, c as boolean)}
className="rounded-lg data-[state=checked]:bg-[#1B2CC1] data-[state=checked]:border-[#1B2CC1]"
/>
<Label htmlFor={`item-${item.id}`} className="text-sm font-bold text-slate-800 dark:text-slate-200 cursor-pointer truncate">
{item.name}
</Label>
</div>
{isSelected && (
<div className="flex items-center gap-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg p-0.5 shadow-sm">
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-500 hover:text-slate-800"
onClick={() => handleStandardItemQty(item.id, qty - 1)}
>
<MinusCircle className="h-4 w-4" />
</Button>
<span className="w-7 text-center text-xs font-extrabold text-[#1B2CC1] dark:text-blue-400">{qty}</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-500 hover:text-slate-800"
onClick={() => handleStandardItemQty(item.id, qty + 1)}
>
<PlusCircle className="h-4 w-4" />
</Button>
</div>
)}
</div>
)
})}
{order.available_items.length === 0 && (
<p className="text-xs text-slate-400 text-center py-3 bg-slate-50 dark:bg-slate-800/40 rounded-xl border border-dashed border-slate-200 dark:border-slate-800">
Tidak ada menu standar yang ditentukan.
</p>
)}
</div>
</div>
{/* Custom Items */}
{order.allow_custom && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs font-bold uppercase tracking-wider text-slate-400">
Item Tambahan (Kustom)
</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={addCustomItem}
className="h-8 text-xs font-bold rounded-lg border-dashed border-[#1B2CC1]/40 text-[#1B2CC1] hover:bg-[#1B2CC1]/10 gap-1.5"
>
<PlusCircle className="h-3.5 w-3.5" /> Tambah Kustom
</Button>
</div>
{customItems.length > 0 ? (
<div className="space-y-2.5">
{customItems.map((ci, idx) => (
<div key={ci.id} className="flex gap-2 items-center p-3 rounded-xl border border-slate-200/90 dark:border-slate-800 bg-slate-50/70 dark:bg-slate-800/40">
<span className="text-xs font-bold text-slate-400 w-4">{idx + 1}.</span>
<Input
placeholder="Nama Menu / Catatan Khusus"
value={ci.name}
onChange={(e) => updateCustomItem(ci.id, 'name', e.target.value)}
className="flex-1 h-9 rounded-lg bg-white dark:bg-slate-900 text-xs font-medium"
/>
<Input
type="number"
min="1"
value={ci.qty}
onChange={(e) => updateCustomItem(ci.id, 'qty', parseInt(e.target.value) || 1)}
className="w-16 h-9 rounded-lg bg-white dark:bg-slate-900 text-center font-bold text-xs"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeCustomItem(ci.id)}
className="h-8 w-8 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-lg"
>
<MinusCircle className="h-4 w-4" />
</Button>
</div>
))}
</div>
) : (
<div
onClick={addCustomItem}
className="p-4 border-2 border-dashed border-slate-200 dark:border-slate-800 hover:border-[#1B2CC1]/50 rounded-2xl text-center bg-slate-50/50 dark:bg-slate-800/30 cursor-pointer transition-all group"
>
<PlusCircle className="w-5 h-5 text-slate-400 group-hover:text-[#1B2CC1] mx-auto mb-1 transition-colors" />
<p className="text-xs font-bold text-slate-600 dark:text-slate-300">Klik untuk Tambah Item Custom</p>
<p className="text-[11px] text-slate-400 mt-0.5">Ingin titip menu lain? Masukkan nama dan kuantitasnya di sini.</p>
</div>
)}
</div>
)}
{error && (
<div className="p-3 bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900 rounded-xl text-xs text-red-600 dark:text-red-400 font-semibold">
{error}
</div>
)}
<div className="flex justify-end gap-3 pt-2 border-t border-slate-100 dark:border-slate-800">
<Button
type="submit"
disabled={loading}
className="w-full h-11 rounded-xl bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold shadow-md shadow-[#1B2CC1]/25"
>
{loading ? 'Menyimpan Titipan...' : 'Kirim Titip Pesanan'}
</Button>
</div>
</form>
</DialogContent>
)
}
+175
View File
@@ -0,0 +1,175 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { cn } from '@/lib/utils'
import {
Store,
ClipboardList,
ShoppingBag,
UserCircle,
Sparkles,
X,
Layers,
ArrowUpRight,
Info
} from 'lucide-react'
import { checkUser } from '@/app/actions'
const menuItems = [
{ name: 'Open Order', href: '/', icon: Store, desc: 'Daftar PO live hari ini' },
{ name: 'Jasa Order Saya', href: '/my-orders', icon: ClipboardList, desc: 'Kelola PO buatan Anda' },
{ name: 'Pesanan Saya', href: '/my-purchases', icon: ShoppingBag, desc: 'Riwayat titipan Anda' },
{ name: 'Profile', href: '/profile', icon: UserCircle, desc: 'Pengaturan akun' },
]
export function Sidebar({ isOpen, onClose }: { isOpen?: boolean; onClose?: () => void }) {
const pathname = usePathname()
const [userName, setUserName] = useState<string>('Pengguna')
const [userPhoto, setUserPhoto] = useState<string | null>(null)
const [userId, setUserId] = useState<string | null>(null)
useEffect(() => {
const id = localStorage.getItem('user_id')
if (id) {
setUserId(id)
checkUser(id).then((u) => {
if (u) {
setUserName(u.name)
if (u.photo) setUserPhoto(u.photo)
}
})
}
}, [pathname])
return (
<>
{/* Mobile Backdrop */}
{isOpen && (
<div
onClick={onClose}
className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-40 lg:hidden transition-opacity"
/>
)}
<aside className={cn(
"fixed lg:sticky top-0 left-0 z-50 h-screen w-72 bg-white dark:bg-slate-900 border-r border-slate-200/80 dark:border-slate-800 flex flex-col justify-between transition-transform duration-300 ease-in-out p-5",
isOpen ? "translate-x-0" : "-translate-x-full lg:translate-x-0"
)}>
<div className="space-y-6">
{/* Brand Header */}
<div className="flex items-center justify-between px-1">
<Link href="/" className="flex items-center gap-3 group">
<div className="w-11 h-11 rounded-2xl bg-gradient-to-br from-[#1B2CC1] to-[#121E85] flex items-center justify-center text-white shadow-lg shadow-[#1B2CC1]/25 transition-all duration-300 group-hover:scale-105 group-hover:shadow-[#1B2CC1]/40">
<Sparkles className="w-5 h-5" />
</div>
<div className="flex flex-col">
<div className="flex items-center gap-1.5">
<span className="font-black text-xl tracking-tight text-slate-900 dark:text-white leading-none">
TitipIn
</span>
<span className="text-[9px] font-extrabold uppercase px-1.5 py-0.5 rounded-md bg-[#1B2CC1]/10 text-[#1B2CC1] dark:bg-blue-900/40 dark:text-blue-300">
Pro
</span>
</div>
<span className="text-[11px] font-medium text-slate-400 mt-1">Sistem Titip Pesanan</span>
</div>
</Link>
{onClose && (
<button onClick={onClose} className="lg:hidden p-1.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100">
<X className="w-5 h-5" />
</button>
)}
</div>
{/* User Profile Card */}
<Link
href="/profile"
onClick={onClose}
className="flex items-center gap-3 p-3 rounded-2xl border border-slate-200/90 dark:border-slate-800 bg-slate-50/70 dark:bg-slate-800/40 hover:bg-slate-100/80 dark:hover:bg-slate-800 transition-all duration-200 group shadow-xs"
>
<div className="relative">
{userPhoto ? (
<img src={userPhoto} alt={userName} className="w-10 h-10 rounded-xl object-cover ring-2 ring-white dark:ring-slate-700 shadow-sm" />
) : (
<div className="w-10 h-10 rounded-xl bg-[#1B2CC1]/10 text-[#1B2CC1] flex items-center justify-center font-black text-sm ring-2 ring-white dark:ring-slate-700 shadow-xs">
{userName.charAt(0).toUpperCase()}
</div>
)}
<span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 bg-emerald-500 rounded-full ring-2 ring-white dark:ring-slate-900 shadow-xs" />
</div>
<div className="flex flex-col min-w-0 flex-1">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-slate-800 dark:text-slate-200 truncate group-hover:text-[#1B2CC1] transition-colors">
{userName}
</span>
<ArrowUpRight className="w-3.5 h-3.5 text-slate-400 opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
<span className="text-[10px] font-mono text-slate-400 truncate">
{userId ? `ID: ${userId.slice(0, 10)}...` : 'Aktif'}
</span>
</div>
</Link>
{/* Main Navigation */}
<div className="space-y-1.5">
<div className="px-3 pb-1.5 text-[10px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-500">
Navigasi Utama
</div>
<nav className="space-y-1">
{menuItems.map((item) => {
const Icon = item.icon
const isActive = pathname === item.href
return (
<Link
key={item.href}
href={item.href}
onClick={onClose}
className={cn(
"flex items-center gap-3 px-3.5 py-3 rounded-2xl text-sm font-semibold transition-all duration-200 group relative",
isActive
? "bg-[#1B2CC1] text-white shadow-md shadow-[#1B2CC1]/25 font-bold"
: "text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100/80 dark:hover:bg-slate-800/60"
)}
>
<div className={cn(
"w-8 h-8 rounded-xl flex items-center justify-center transition-colors",
isActive
? "bg-white/15 text-white"
: "bg-slate-100 dark:bg-slate-800 text-slate-500 group-hover:text-[#1B2CC1] group-hover:bg-[#1B2CC1]/10"
)}>
<Icon className="w-4 h-4" />
</div>
<div className="flex flex-col min-w-0">
<span className="leading-tight">{item.name}</span>
<span className={cn(
"text-[10px] font-normal truncate mt-0.5",
isActive ? "text-blue-100" : "text-slate-400"
)}>
{item.desc}
</span>
</div>
</Link>
)
})}
</nav>
</div>
</div>
{/* Bottom Feature Card */}
<div className="p-4 rounded-2xl bg-gradient-to-br from-blue-50/80 to-slate-50 dark:from-slate-800/60 dark:to-slate-900 border border-blue-100/80 dark:border-slate-800 space-y-2">
<div className="flex items-center gap-2 text-xs font-bold text-slate-800 dark:text-slate-200">
<div className="w-5 h-5 rounded-lg bg-[#1B2CC1]/10 text-[#1B2CC1] flex items-center justify-center">
<Info className="w-3 h-3" />
</div>
<span>Auto Rekap WhatsApp</span>
</div>
<p className="text-[11px] text-slate-500 dark:text-slate-400 leading-relaxed">
Gunakan Generator Rekap pada detail PO untuk salin ringkasan belanja otomatis ke chat grup.
</p>
</div>
</aside>
</>
)
}
+52
View File
@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }
+58
View File
@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+221
View File
@@ -0,0 +1,221 @@
"use client"
import * as React from "react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
type Locale,
} from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
locale,
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
locale={locale}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString(locale?.code, { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative rounded-(--cell-radius)",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute inset-0 bg-popover opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"font-medium select-none",
captionLayout === "label"
? "text-sm"
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label
),
month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-(--cell-size) select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] text-muted-foreground select-none",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
defaultClassNames.day
),
range_start: cn(
"relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn(
"relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
defaultClassNames.range_end
),
today: cn(
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon className={cn("size-4", className)} {...props} />
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: ({ ...props }) => (
<CalendarDayButton locale={locale} {...props} />
),
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
locale,
...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString(locale?.code)}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }
+103
View File
@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+29
View File
@@ -0,0 +1,29 @@
"use client"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "@/lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground group-has-[:focus-visible]/field-label:data-checked:border-primary dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+164
View File
@@ -0,0 +1,164 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
closeClassName,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
closeClassName?: string
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className={cn(
"absolute top-3 right-3 rounded-full transition-all z-10",
closeClassName || "text-slate-500 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-100 dark:hover:bg-slate-800"
)}
size="icon-sm"
/>
}
>
<XIcon className="w-4 h-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+20
View File
@@ -0,0 +1,20 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+90
View File
@@ -0,0 +1,90 @@
"use client"
import * as React from "react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "@/lib/utils"
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return (
<PopoverPrimitive.Title
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: PopoverPrimitive.Description.Props) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}
+201
View File
@@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+116
View File
@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+15
View File
@@ -0,0 +1,15 @@
import { PrismaClient } from '@prisma/client'
const prismaClientSingleton = () => {
return new PrismaClient()
}
declare const globalThis: {
prismaGlobal: ReturnType<typeof prismaClientSingleton>;
} & typeof global;
const prisma = globalThis.prismaGlobal ?? prismaClientSingleton()
export default prisma
if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}