feat(payment-frontend): ship Torbat Pay admin portal for MVP 14.0-14.5

Add payment module, BFF proxy, hub/dashboard/ops/PSP/merchant screens wired to real APIs, and mark payment-frontend complete in project status.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Mortezakoohjani 2026-07-27 18:35:07 +03:30
parent 9fac160258
commit 4451b32a33
136 changed files with 5181 additions and 8 deletions

View File

@ -554,6 +554,8 @@ services:
BEAUTY_BUSINESS_SERVICE_URL: ${BEAUTY_BUSINESS_SERVICE_URL:-http://beauty-business-service:8011} BEAUTY_BUSINESS_SERVICE_URL: ${BEAUTY_BUSINESS_SERVICE_URL:-http://beauty-business-service:8011}
NEXT_PUBLIC_HOSPITALITY_API_URL: ${NEXT_PUBLIC_HOSPITALITY_API_URL:-http://localhost:8009} NEXT_PUBLIC_HOSPITALITY_API_URL: ${NEXT_PUBLIC_HOSPITALITY_API_URL:-http://localhost:8009}
HOSPITALITY_SERVICE_URL: ${HOSPITALITY_SERVICE_URL:-http://hospitality-service:8009} HOSPITALITY_SERVICE_URL: ${HOSPITALITY_SERVICE_URL:-http://hospitality-service:8009}
NEXT_PUBLIC_PAYMENT_API_URL: ${NEXT_PUBLIC_PAYMENT_API_URL:-http://localhost:8012}
PAYMENT_SERVICE_URL: ${PAYMENT_SERVICE_URL:-http://payment-service:8012}
# polling برای hot-reload پایدار روی volume mount ویندوز # polling برای hot-reload پایدار روی volume mount ویندوز
WATCHPACK_POLLING: "true" WATCHPACK_POLLING: "true"
CHOKIDAR_USEPOLLING: "true" CHOKIDAR_USEPOLLING: "true"

View File

@ -4,9 +4,9 @@
## Payment Platform — MVP complete (Torbat Pay) ## Payment Platform — MVP complete (Torbat Pay)
Phases **payment-14.0 through payment-14.5** are implemented (v**0.14.5.0**) per [phase-handover/phase-14-5.md](phase-handover/phase-14-5.md) and [service-snapshots/payment.yaml](service-snapshots/payment.yaml). Phases **payment-14.0 through payment-14.5** backend (v**0.14.5.0**) per [phase-handover/phase-14-5.md](phase-handover/phase-14-5.md). **Payment frontend (payment-frontend)** complete per [phase-handover/phase-payment-frontend.md](phase-handover/phase-payment-frontend.md) and [service-snapshots/payment-frontend.yaml](service-snapshots/payment-frontend.yaml).
**Next implementation phase:** `payment-14.6` Refunds & Reversals (**LATER** priority — requires Communication integration). **Next implementation phase:** `payment-14.6` Refunds & Reversals (**LATER** priority — requires Communication integration). No 14.6+ payment UI until backend phase lands.
Remaining documented phases: 14.614.10 (refunds, splits, connectors, reconciliation, enterprise validation). Remaining documented phases: 14.614.10 (refunds, splits, connectors, reconciliation, enterprise validation).
@ -28,7 +28,7 @@ Pick one track per team capacity (see [roadmap.md](roadmap.md) and [phase-manife
| Track | Next phase | Notes | | Track | Next phase | Notes |
| --- | --- | --- | | --- | --- | --- |
| **Payment (Torbat Pay)** | `payment-14.6` Refunds & Reversals | MVP 14.014.5 complete; LATER priority | | **Payment (Torbat Pay)** | `payment-14.6` Refunds & Reversals | Backend MVP 14.014.5 + frontend complete; LATER priority |
| Delivery | `delivery-10.2` Fleet & Vehicle Types | After Driver Management 10.1 | | Delivery | `delivery-10.2` Fleet & Vehicle Types | After Driver Management 10.1 |
| Loyalty | `loyalty-7.7` Gift Card Platform | After Wallet 7.6 | | Loyalty | `loyalty-7.7` Gift Card Platform | After Wallet 7.6 |
| Hospitality | `hospitality-12.9` AI Assistant | After Analytics 12.8 | | Hospitality | `hospitality-12.9` AI Assistant | After Analytics 12.8 |

View File

@ -0,0 +1,46 @@
# Phase Handover — Payment Frontend (payment-frontend)
**Completed:** 2026-07-27
**Service:** Torbat Pay — Enterprise Payment Platform
**Backend version at handover:** 0.14.5.0 (phases 14.014.5)
## Summary
Full MVP admin portal for Torbat Pay delivered under `frontend/modules/payment/` and `frontend/app/payment/**`. Persian labels, real API wiring via BFF to port 8012, recharts on dashboard/KPIs.
## Deliverables
- 26 feature modules under `modules/payment/features/`
- Thin Next.js app routes with loading/error boundaries
- BFF `app/api/payment/[...path]/route.ts` (Idempotency-Key forwarded)
- `scripts/validate-payment-routes.mjs` + `npm run validate:payment`
- Apps catalog entry: Torbat Pay → `/payment/hub`
- Docker env: `NEXT_PUBLIC_PAYMENT_API_URL`, `PAYMENT_SERVICE_URL`
- Snapshots/docs: `payment-frontend.yaml`, architecture + validation reports
## Checkout session mapping
`PaymentRequest` detail page (`/payment/requests/[id]`) is the checkout session view — cancel, verify, redirect URL display.
## Stop boundary
**Do not implement 14.6+ UI** (refunds, splits, subscriptions) until backend phases complete.
## Next backend phase
`payment-14.6` Refunds & Reversals — frontend remains list/read for MVP until explicitly scoped.
## Verification
```bash
cd frontend
npm run validate:payment
npm run typecheck
```
## References
- [payment-frontend-architecture.md](../../frontend/docs/payment-frontend-architecture.md)
- [payment-frontend-validation-report.md](../../frontend/docs/payment-frontend-validation-report.md)
- [service-snapshots/payment-frontend.yaml](../service-snapshots/payment-frontend.yaml)
- [service-snapshots/payment.yaml](../service-snapshots/payment.yaml)

View File

@ -1075,6 +1075,21 @@ Remaining (honest): dispatch/fleet/tracking/maps live/realtime/settlement screen
--- ---
## Phase Payment Frontend — Torbat Pay Admin Portal ✅
> `frontend/modules/payment/` + `frontend/app/payment/**` — MVP UI for phases 14.014.5 only.
- [x] 26 feature screens (hub, dashboard, KPIs, transactions, ledger, PSP, merchants, requests, audit, bundles, toggles, capabilities, settings, metrics, monitoring, API explorer, OpenAPI)
- [x] BFF `/api/payment` → port 8012; Idempotency-Key forwarded
- [x] Checkout session = `PaymentRequest` detail (`/payment/requests/[id]`)
- [x] Apps catalog: Torbat Pay → `/payment/hub`
- [x] Validation: `npm run validate:payment`
- [x] Handover: [phase-handover/phase-payment-frontend.md](phase-handover/phase-payment-frontend.md)
- [x] Snapshot: [service-snapshots/payment-frontend.yaml](service-snapshots/payment-frontend.yaml)
- [x] **14.6+ UI not started** (refunds, splits — by design)
---
## Related Documents ## Related Documents
- [Next Steps](next-steps.md) - [Next Steps](next-steps.md)

122
docs/project-status.md Normal file
View File

@ -0,0 +1,122 @@
# Project Status — TorbatYar Single Execution Dashboard (v2)
**Authoritative machine file:** [`project-status.yaml`](project-status.yaml)
This is the **first document** every AI implementation run must read. When it exists, agents must **not** full-scan the repository or inspect unrelated services.
| Field | Value |
| --- | --- |
| Schema | **2** |
| Project version | `0.14.5-platform` |
| Last updated | 2026-07-27 |
| Last completed phase | `platform-project-status-v2` |
**Deprecated:** `next_recommended_phase` → replaced by `execution_priority` + `critical_path` + `deployment_readiness`.
---
## Resume rules (every execution)
1. Read `docs/project-status.yaml` **FIRST**.
2. Never inspect unrelated services.
3. Never scan the repository for completed work.
4. Determine remaining work **exclusively** from this file.
5. Load only documents required by [Runtime Read Policy](ai-framework/runtime-read-policy.md) for the active service.
6. Execute higher `execution_priority` before lower.
7. Follow `critical_path` order for commercial-release sequencing.
---
## Critical path (first commercial release)
Ordered remaining milestones:
1. **Payment Backend MVP**`payment-14.6``payment-14.10`
2. **Payment Frontend**
3. **Experience Frontend** — Publish Registry `experience-fe-11.6+`
4. **Restaurant Production**`hospitality-12.9``12.10`
5. **Driver Production**`delivery-10.2``10.10`
6. **Medical Booking**
7. **Booking**
8. **Short Links**
9. **QR**
10. **Digital Card**
---
## Execution priority
| Bucket | Phases |
| --- | --- |
| **critical** | `payment-14.6``14.10`, `payment-frontend`, `hospitality-12.9``12.10` |
| **high** | `delivery-10.2``10.10`, `experience-fe-11.6`, `communication-frontend-complete` |
| **medium** | `loyalty-7.7``7.10`, `sports-center-9.8``9.10`, `accounting-5.12` |
| **low** | Identity / CRM / Healthcare / Beauty frontend polish |
| **future** | Ecommerce, Marketplace, Automation, Academy, Clinic, Hotel, Tourism, AI, SMS Panel, Short Links, QR, Digital Card, Booking, Notification, File Storage, Live Chat, Messenger |
---
## Global statistics
| Metric | Value |
| --- | --- |
| Number of services | 27 |
| Completed services | 6 |
| In progress | 7 |
| Planned / scaffolded | 14 |
| Backend completion | ~48% |
| Frontend completion | ~22% |
| Overall completion | ~35% |
| Critical path remaining | 10 |
---
## Deployment readiness
Official indicator per service:
```yaml
deployment_readiness:
backend: NOT_STARTED | IN_PROGRESS | COMPLETE
frontend: NOT_STARTED | IN_PROGRESS | COMPLETE
database: NOT_STARTED | IN_PROGRESS | COMPLETE
api: NOT_STARTED | IN_PROGRESS | COMPLETE
tests: NOT_STARTED | IN_PROGRESS | COMPLETE
documentation: NOT_STARTED | IN_PROGRESS | COMPLETE
production_ready: NOT_STARTED | IN_PROGRESS | COMPLETE
```
---
## Service completion (canonical)
| Service | Completed | Remaining | BE% | FE% | Overall% | Prod ready |
| --- | --- | --- | --- | --- | --- | --- |
| Core | onboarding-4 | — | 100 | 100 | 100 | COMPLETE |
| Identity | identity-2 | FE polish | 100 | 70 | 85 | COMPLETE |
| Accounting | 5.11 | 5.12 | 92 | 40 | 66 | IN_PROGRESS |
| CRM | 6.3 | FE polish | 100 | 55 | 78 | COMPLETE |
| Loyalty | 7.6 | 7.77.10 | 60 | 0 | 30 | IN_PROGRESS |
| Communication | 8 | FE complete | 100 | 65 | 82 | COMPLETE |
| Sports Center | 9.7 | 9.89.10 | 70 | 0 | 35 | IN_PROGRESS |
| Delivery | 10.1 | 10.210.10 | 18 | 25 | 22 | IN_PROGRESS |
| Experience | FE-11.5 | FE-11.6+ | 100 | 83 | 92 | IN_PROGRESS |
| Hospitality | 12.8 | 12.912.10 | 80 | 0 | 40 | IN_PROGRESS |
| Healthcare | 13-7 | FE polish | 100 | 55 | 78 | COMPLETE |
| Beauty | 14-7 | FE polish | 100 | 55 | 78 | COMPLETE |
| Payment | 14.5 | 14.614.10 + FE | 50 | 0 | 25 | IN_PROGRESS |
| Future products | — | foundations | 05 | 0 | 02 | NOT_STARTED |
## Dependency graph
Use `depends_on` / `required_by` on each service row — do not derive by scanning code.
## Automatic updates
After every Complete phase, update: completed/remaining phases, deployment readiness, percentages, critical path, execution priorities, service completion, timestamps. No manual editing when following the Documentation Update stage.
## Framework refs
[Runtime Read Policy](ai-framework/runtime-read-policy.md) · [Context Cache](ai-framework/context-cache-policy.md) · [Service Snapshot](ai-framework/service-snapshot-policy.md) · [Development Loop](ai-framework/development-loop.md) · [Master Prompt](ai-framework/master-prompt.md)
Handover: [phase-af-project-status-v2.md](phase-handover/phase-af-project-status-v2.md)

1200
docs/project-status.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,78 @@
# docs/service-snapshots/payment-frontend.yaml
schema_version: 1
snapshot_version: 1
service_name: payment
layer: frontend
commercial_product: Torbat Pay
backend_version: "0.14.5.0"
current_phase: payment-frontend
last_completed_phase: payment-frontend
next_phase: payment-14.6
stop_boundary: MVP 14.014.5 (no refunds/splits UI)
completed_phases:
- payment-frontend
module_path: frontend/modules/payment
bff_path: frontend/app/api/payment/[...path]/route.ts
app_routes_prefix: /payment
route_count: 26
env_vars:
- NEXT_PUBLIC_PAYMENT_API_URL
- PAYMENT_SERVICE_URL
apps_catalog:
id: payment
href: /payment/hub
status: available
features:
- hub
- dashboard
- overview
- kpis
- transactions
- transactionDetail
- ledger
- psp
- gatewayHealth
- providers
- merchants
- merchantDetail
- merchantApprovals
- requests
- requestDetail
- callbacks
- verification
- audit
- bundles
- featureToggles
- capabilities
- settings
- metrics
- monitoring
- apiExplorer
- openapi
api_client: frontend/modules/payment/services/payment-api.ts
real_backend_only: true
mock_data: false
documentation:
- docs/payment-roadmap.md
- frontend/docs/payment-frontend-architecture.md
- frontend/docs/payment-frontend-validation-report.md
- docs/phase-handover/phase-payment-frontend.md
- docs/service-snapshots/payment.yaml
open_todos:
- payment-14.6+ Refunds & Reversals UI (explicitly out of scope)
- payment-14.7+ Splits UI (explicitly out of scope)
- Dedicated GET /callback-logs endpoint when backend exposes list API
- Fine-grained permission gating per screen from identity membership
last_handover_reference: docs/phase-handover/phase-payment-frontend.md
last_updated: "2026-07-27"

View File

@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from "next/server";
const UPSTREAM =
process.env.PAYMENT_SERVICE_URL ||
process.env.NEXT_PUBLIC_PAYMENT_API_URL ||
"http://localhost:8012";
async function proxy(req: NextRequest, pathSegments: string[]) {
const p = pathSegments.join("/");
const url = new URL(req.url);
const target = `${UPSTREAM.replace(/\/$/, "")}/${p}${url.search}`;
const headers = new Headers();
for (const h of [
"authorization",
"content-type",
"x-tenant-id",
"accept",
"idempotency-key",
]) {
const v = req.headers.get(h);
if (v) headers.set(h, v);
}
let body: ArrayBuffer | undefined;
if (req.method !== "GET" && req.method !== "HEAD") body = await req.arrayBuffer();
try {
const res = await fetch(target, { method: req.method, headers, body, cache: "no-store" });
const out = new Headers();
const ct = res.headers.get("content-type");
if (ct) out.set("content-type", ct);
return new NextResponse(await res.arrayBuffer(), { status: res.status, headers: out });
} catch (err) {
const detail = err instanceof Error ? err.message : "upstream_unreachable";
return NextResponse.json(
{ error: { code: "payment_proxy_error", message: `پروکسی تربت‌پی — ${detail}` } },
{ status: 502 }
);
}
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
const { path: p } = await ctx.params;
return proxy(req, p);
}
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
const { path: p } = await ctx.params;
return proxy(req, p);
}
export async function PATCH(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
const { path: p } = await ctx.params;
return proxy(req, p);
}
export async function PUT(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
const { path: p } = await ctx.params;
return proxy(req, p);
}
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
const { path: p } = await ctx.params;
return proxy(req, p);
}

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری تربت‌پی…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { PaymentApiExplorer as default } from "@/modules/payment/features/apiExplorer";

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری تربت‌پی…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { PaymentAudit as default } from "@/modules/payment/features/audit";

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری تربت‌پی…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { PaymentBundles as default } from "@/modules/payment/features/bundles";

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری تربت‌پی…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { PaymentCallbacks as default } from "@/modules/payment/features/callbacks";

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری تربت‌پی…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { PaymentCapabilities as default } from "@/modules/payment/features/capabilities";

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری تربت‌پی…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { PaymentDashboard as default } from "@/modules/payment/features/dashboard";

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری تربت‌پی…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { PaymentFeatureToggles as default } from "@/modules/payment/features/featureToggles";

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری تربت‌پی…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { PaymentHub as default } from "@/modules/payment/features/hub";

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری تربت‌پی…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { PaymentKpis as default } from "@/modules/payment/features/kpis";

View File

@ -0,0 +1,5 @@
"use client";
import { PaymentRootLayout } from "@/modules/payment/components/createPortalLayout";
export default PaymentRootLayout;

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری تربت‌پی…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { PaymentLedger as default } from "@/modules/payment/features/ledger";

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری تربت‌پی…" />;
}

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری تربت‌پی…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { PaymentMerchantDetail as default } from "@/modules/payment/features/merchantDetail";

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری تربت‌پی…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { PaymentMerchantApprovals as default } from "@/modules/payment/features/merchantApprovals";

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری تربت‌پی…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { PaymentMerchants as default } from "@/modules/payment/features/merchants";

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری تربت‌پی…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { PaymentMetrics as default } from "@/modules/payment/features/metrics";

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری تربت‌پی…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { PaymentMonitoring as default } from "@/modules/payment/features/monitoring";

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری تربت‌پی…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { PaymentOpenApi as default } from "@/modules/payment/features/openapi";

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری تربت‌پی…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { PaymentOverview as default } from "@/modules/payment/features/overview";

View File

@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function PaymentIndexPage() {
redirect("/payment/hub");
}

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری تربت‌پی…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { PaymentProviders as default } from "@/modules/payment/features/providers";

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری تربت‌پی…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { PaymentGatewayHealth as default } from "@/modules/payment/features/gatewayHealth";

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری تربت‌پی…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { PaymentPsp as default } from "@/modules/payment/features/psp";

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری تربت‌پی…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { PaymentRequestDetail as default } from "@/modules/payment/features/requestDetail";

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری تربت‌پی…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { PaymentRequests as default } from "@/modules/payment/features/requests";

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری تربت‌پی…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { PaymentSettings as default } from "@/modules/payment/features/settings";

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری تربت‌پی…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { PaymentTransactionDetail as default } from "@/modules/payment/features/transactionDetail";

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری تربت‌پی…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { PaymentTransactions as default } from "@/modules/payment/features/transactions";

View File

@ -0,0 +1,5 @@
"use client";
import { ErrorState } from "@/components/ds";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return <ErrorState message={error.message} onRetry={reset} />;
}

View File

@ -0,0 +1,4 @@
import { LoadingState } from "@/components/ds";
export default function Loading() {
return <LoadingState label="در حال بارگذاری تربت‌پی…" />;
}

View File

@ -0,0 +1,2 @@
"use client";
export { PaymentVerification as default } from "@/modules/payment/features/verification";

View File

@ -0,0 +1,44 @@
# Payment Frontend Architecture
Torbat Pay (`frontend/modules/payment/`) mirrors the Experience/Delivery module layout.
## Layers
```
Browser → /api/payment BFF → payment-service:8012
```
- **BFF:** `frontend/app/api/payment/[...path]/route.ts` — forwards `Authorization`, `X-Tenant-ID`, `Idempotency-Key`
- **API client:** `services/payment-api.ts` — real endpoints only (MVP 14.014.5)
- **Checkout session:** `PaymentRequest` detail at `/payment/requests/[id]`
## Module structure
| Path | Role |
| --- | --- |
| `components/` | PaymentPortalShell, PaymentListPage, PaymentCharts, createPortalLayout |
| `constants/` | portals.ts (nav + MVP routes), permissions.ts |
| `features/` | Screen implementations (26 files) |
| `hooks/` | usePaymentCapabilities, usePaymentPermissions |
| `pages/shared.tsx` | Loaders, errors, CSV export |
| `types/` | API response types |
## Auth & tenant
- `AuthGuard` + `useMe` tenant gate (same as other portals)
- `X-Tenant-ID` on all authenticated API calls
- Bundle-gated nav via `filterNavByBundles(active_bundles)`
## Charts
Dashboard and KPIs use `PaymentCharts` (recharts): bar, donut, stat cards.
## Validation
```bash
npm run validate:payment
```
## Out of scope (14.6+)
Refunds, splits, subscriptions, wallet top-up UI — backend phases not yet implemented.

View File

@ -0,0 +1,55 @@
# Payment Frontend Validation Report
**Date:** 2026-07-27
**Phase:** payment-frontend
**Backend boundary:** MVP 14.014.5 (v0.14.5.0)
## Route validation
```bash
npm run validate:payment-routes
# Payment routes OK (23 MVP routes, 26 features)
```
## Module inventory
| Category | Count |
| --- | --- |
| Feature screens | 26 |
| MVP list routes | 23 |
| Detail routes | 3 (`requests/[id]`, `transactions/[id]`, `merchants/[id]`) |
| Core components (pre-existing) | 5 |
| Hooks | 2 |
| BFF proxy | 1 |
## API wiring
All list/detail screens call `paymentApi` methods against real `/api/v1/*` paths. No mock fixtures.
| Screen | API |
| --- | --- |
| Dashboard / KPIs | `requests.list`, `transactions.list`, `audit.list`, `metrics` |
| Transactions | `transactions.list/get` |
| Ledger | `ledger.list` |
| PSP | `pspConnections.list/test` |
| Merchants | `merchants.*` lifecycle |
| Requests | `requests.list/get/cancel/verify` |
| Callbacks | `audit.list` + `requests` filtered client-side |
| Verification | `requests.verify` queue |
| Bundles / toggles / settings | CRUD list endpoints |
| OpenAPI | `GET /openapi.json` |
## Known gaps
1. **Callback log list** — backend exposes POST callback ingress only; UI derives from audit + `redirect_issued` requests.
2. **14.6+ UI** — refunds, splits, reconciliation not implemented (by design).
3. **Create flows** — list pages are read-focused; POST create forms for workspace/PSP/merchant can be added incrementally.
4. **Permission UI gates**`usePaymentPermissions` available; per-screen enforcement is soft (403 from API).
## Apps catalog
Torbat Pay registered as `available``/payment/hub`.
## Docker env
`NEXT_PUBLIC_PAYMENT_API_URL` + `PAYMENT_SERVICE_URL` added to frontend service in docker-compose.

View File

@ -103,12 +103,22 @@ export const PLATFORM_APPS: PlatformApp[] = [
status: "coming_soon", status: "coming_soon",
}, },
{ {
id: "website_builder", id: "experience",
name: "سایت‌ساز", name: "تربت پیجز",
description: "ساخت صفحات و لندینگ", description: "سایت، صفحه، قالب، فرم و انتشار",
icon: "🌐", icon: "🌐",
gradient: "from-cyan-500 to-sky-600", gradient: "from-cyan-500 to-sky-600",
status: "coming_soon", status: "available",
href: "/experience/hub",
},
{
id: "payment",
name: "تربت‌پی",
description: "پلتفرم پرداخت سازمانی — BYO-PSP و پذیرنده",
icon: "💳",
gradient: "from-teal-500 to-emerald-600",
status: "available",
href: "/payment/hub",
}, },
{ {
id: "restaurant", id: "restaurant",

View File

@ -0,0 +1,84 @@
"use client";
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
const COLORS = ["#0d9488", "#0284c7", "#d97706", "#e11d48", "#6366f1", "#64748b"];
export function PaymentBarChart({
data,
dataKey = "value",
nameKey = "name",
}: {
data: { name: string; value: number }[];
dataKey?: string;
nameKey?: string;
}) {
if (!data.length) {
return <p className="text-sm text-[var(--muted)]">دادهای برای نمودار نیست.</p>;
}
return (
<div className="h-56 w-full" role="img" aria-label="نمودار میله‌ای">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey={nameKey} tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip />
<Bar dataKey={dataKey} fill="#0d9488" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
);
}
export function PaymentDonutChart({ data }: { data: { name: string; value: number }[] }) {
if (!data.length) {
return <p className="text-sm text-[var(--muted)]">دادهای برای نمودار نیست.</p>;
}
return (
<div className="h-56 w-full" role="img" aria-label="نمودار دایره‌ای">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={data} dataKey="value" nameKey="name" innerRadius={50} outerRadius={80}>
{data.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
);
}
export function PaymentStatCards({
items,
}: {
items: { label: string; value: string | number; hint?: string }[];
}) {
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{items.map((item) => (
<div
key={item.label}
className="rounded-xl border border-[var(--border)] bg-[var(--surface)] p-4"
>
<p className="text-xs text-[var(--muted)]">{item.label}</p>
<p className="mt-1 text-2xl font-semibold text-secondary">{item.value}</p>
{item.hint ? <p className="mt-1 text-[11px] text-[var(--muted)]">{item.hint}</p> : null}
</div>
))}
</div>
);
}

View File

@ -0,0 +1,248 @@
"use client";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Download, RefreshCw, Search } from "lucide-react";
import { Button, Input, Select } from "@/components/ds";
import { useTenantId } from "@/hooks/useTenantId";
import { PaymentStatusChip } from "@/modules/payment/components/PaymentStatusChip";
import {
PaymentEmptyState,
PaymentPageError,
PaymentPageLoader,
exportToCsv,
} from "@/modules/payment/pages/shared";
import type { PaymentRecord } from "@/modules/payment/types";
type Column = {
key: string;
header: string;
type?: "status" | "datetime" | "text";
render?: (row: PaymentRecord) => React.ReactNode;
};
type Props = {
title: string;
description?: string;
resourceKey: string;
columns: Column[];
fetcher: (tenantId: string) => Promise<PaymentRecord[]>;
filterKey?: string;
detailHref?: (row: PaymentRecord) => string | null;
rowActions?: React.ReactNode | ((row: PaymentRecord, refresh: () => void) => React.ReactNode);
headerActions?: React.ReactNode;
};
const PAGE_SIZE = 10;
export function PaymentListPage({
title,
description,
resourceKey,
columns,
fetcher,
filterKey = "status",
detailHref,
rowActions,
headerActions,
}: Props) {
const { tenantId } = useTenantId();
const [search, setSearch] = useState("");
const [filter, setFilter] = useState("");
const [sortKey, setSortKey] = useState(columns[0]?.key || "created_at");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
const [page, setPage] = useState(1);
const q = useQuery({
queryKey: ["payment", tenantId, resourceKey],
queryFn: () => fetcher(tenantId!),
enabled: !!tenantId,
});
const rows = useMemo(() => {
let data = [...(q.data ?? [])];
if (filter) data = data.filter((r) => String(r[filterKey] ?? "") === filter);
if (search.trim()) {
const s = search.trim().toLowerCase();
data = data.filter((r) => JSON.stringify(r).toLowerCase().includes(s));
}
data.sort((a, b) => {
const av = String(a[sortKey] ?? "");
const bv = String(b[sortKey] ?? "");
return sortDir === "asc" ? av.localeCompare(bv) : bv.localeCompare(av);
});
return data;
}, [q.data, filter, filterKey, search, sortKey, sortDir]);
const filterOptions = useMemo(() => {
const set = new Set((q.data ?? []).map((r) => String(r[filterKey] ?? "")).filter(Boolean));
return Array.from(set);
}, [q.data, filterKey]);
const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
const pageRows = rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
if (!tenantId || q.isLoading) return <PaymentPageLoader />;
if (q.isError) return <PaymentPageError error={q.error} onRetry={() => q.refetch()} />;
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-xl font-semibold text-secondary">{title}</h1>
{description ? <p className="mt-1 text-sm text-[var(--muted)]">{description}</p> : null}
</div>
<div className="flex flex-wrap gap-2">
{headerActions}
<Button variant="outline" size="sm" onClick={() => q.refetch()} aria-label="بازنشانی">
<RefreshCw className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
exportToCsv(
resourceKey,
rows,
columns.map((c) => c.key)
)
}
>
<Download className="ml-1 h-4 w-4" />
خروجی
</Button>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<div className="relative flex-1">
<Search className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--muted)]" />
<Input
className="pr-9"
placeholder="جستجو…"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
aria-label="جستجو"
/>
</div>
<Select
value={filter}
onChange={(e) => {
setFilter(e.target.value);
setPage(1);
}}
aria-label="فیلتر وضعیت"
>
<option value="">همه وضعیتها</option>
{filterOptions.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</Select>
<Select
value={`${sortKey}:${sortDir}`}
onChange={(e) => {
const [k, d] = e.target.value.split(":");
setSortKey(k);
setSortDir(d as "asc" | "desc");
}}
aria-label="مرتب‌سازی"
>
{columns.map((c) => (
<option key={`${c.key}-desc`} value={`${c.key}:desc`}>
{c.header}
</option>
))}
{columns.map((c) => (
<option key={`${c.key}-asc`} value={`${c.key}:asc`}>
{c.header}
</option>
))}
</Select>
</div>
{rows.length === 0 ? (
<PaymentEmptyState />
) : (
<div className="overflow-x-auto rounded-xl border border-[var(--border)] bg-[var(--surface)]">
<table className="min-w-full text-sm">
<thead className="bg-[var(--surface-muted)] text-[var(--muted)]">
<tr>
{columns.map((c) => (
<th key={c.key} className="px-3 py-2 text-right font-medium">
{c.header}
</th>
))}
{(detailHref || rowActions) && (
<th className="px-3 py-2 text-right font-medium">عملیات</th>
)}
</tr>
</thead>
<tbody>
{pageRows.map((row, idx) => (
<tr key={String(row.id ?? idx)} className="border-t border-[var(--border)]">
{columns.map((c) => (
<td key={c.key} className="px-3 py-2 text-secondary">
{c.render
? c.render(row)
: c.type === "status"
? <PaymentStatusChip status={String(row[c.key] ?? "")} />
: c.type === "datetime" && row[c.key]
? new Date(String(row[c.key])).toLocaleString("fa-IR")
: String(row[c.key] ?? "—")}
</td>
))}
{(detailHref || rowActions) && (
<td className="px-3 py-2">
<div className="flex flex-wrap gap-2">
{detailHref?.(row) ? (
<a
href={detailHref(row)!}
className="text-xs font-medium text-[var(--payment-accent,#0d9488)] hover:underline"
>
جزئیات
</a>
) : null}
{typeof rowActions === "function"
? rowActions(row, () => q.refetch())
: rowActions}
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="flex items-center justify-between text-xs text-[var(--muted)]">
<span>
{rows.length} مورد صفحه {page} از {totalPages}
</span>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
قبلی
</Button>
<Button
size="sm"
variant="outline"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
بعدی
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,184 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ChevronDown, Moon, Sun, Menu, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { useColorMode } from "@/components/providers/ColorModeProvider";
import { Button } from "@/components/ds";
import {
PAYMENT_PORTAL,
navForPayment,
filterNavByBundles,
} from "@/modules/payment/constants/portals";
import { usePaymentCapabilities } from "@/modules/payment/hooks/usePaymentCapabilities";
function isActive(pathname: string, href: string) {
const base = href.split("?")[0];
if (base === "/payment") return pathname === "/payment";
return pathname === base || pathname.startsWith(`${base}/`);
}
export function PaymentPortalShell({
children,
title,
}: {
children: React.ReactNode;
title?: string;
}) {
const pathname = usePathname();
const { mode, toggle } = useColorMode();
const caps = usePaymentCapabilities();
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
const [mobileOpen, setMobileOpen] = useState(false);
const nav = useMemo(
() => filterNavByBundles(navForPayment(), caps.data?.active_bundles),
[caps.data?.active_bundles]
);
const autoOpen = useMemo(() => {
const map: Record<string, boolean> = {};
for (const g of nav) {
if (g.href && isActive(pathname, g.href)) map[g.id] = true;
if (g.items?.some((i) => isActive(pathname, i.href))) map[g.id] = true;
}
return map;
}, [pathname, nav]);
const expanded = { ...autoOpen, ...openGroups };
const sidebar = (
<>
<div className="mb-4 px-2">
<p className="text-xs text-[var(--muted)]">تربتپی</p>
<p className="font-semibold text-secondary">{title ?? PAYMENT_PORTAL.label}</p>
</div>
<nav className="space-y-0.5" aria-label="ناوبری تربت‌پی">
{nav.map((group) => {
const Icon = group.icon;
const hasChildren = !!group.items?.length;
const groupActive = group.href
? isActive(pathname, group.href)
: group.items?.some((i) => isActive(pathname, i.href));
const isOpen = expanded[group.id] ?? false;
if (!hasChildren && group.href) {
return (
<Link
key={group.id}
href={group.href}
onClick={() => setMobileOpen(false)}
className={cn(
"flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-all",
groupActive
? "bg-teal-600 text-white shadow-sm"
: "text-[var(--muted)] hover:bg-[var(--surface-muted)] hover:text-secondary"
)}
>
<Icon className="h-4 w-4 shrink-0" />
{group.label}
</Link>
);
}
return (
<div key={group.id}>
<button
type="button"
onClick={() =>
setOpenGroups((prev) => ({
...prev,
[group.id]: !(expanded[group.id] ?? false),
}))
}
className={cn(
"flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-colors",
groupActive
? "bg-[var(--surface-muted)] text-secondary"
: "text-[var(--muted)] hover:bg-[var(--surface-muted)]"
)}
>
<Icon className="h-4 w-4 shrink-0" />
<span className="flex-1 text-right">{group.label}</span>
<ChevronDown
className={cn("h-4 w-4 transition-transform", isOpen && "rotate-180")}
/>
</button>
{isOpen ? (
<div className="mb-1 mr-3 space-y-0.5 border-r border-[var(--border)] pr-2">
{group.items?.map((item) => (
<Link
key={item.href}
href={item.href}
onClick={() => setMobileOpen(false)}
className={cn(
"block rounded-lg px-3 py-1.5 text-xs font-medium transition-colors",
isActive(pathname, item.href)
? "bg-teal-600 text-white"
: "text-[var(--muted)] hover:bg-[var(--surface-muted)]"
)}
>
{item.label}
</Link>
))}
</div>
) : null}
</div>
);
})}
</nav>
<div className="mt-auto space-y-2 border-t border-[var(--border)] pt-4">
<Link
href="/payment/hub"
className="block rounded-lg px-3 py-2 text-xs text-[var(--muted)] hover:text-secondary"
>
هاب تربتپی
</Link>
<Button variant="ghost" size="sm" className="w-full justify-start gap-2" onClick={toggle}>
{mode === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
{mode === "dark" ? "حالت روشن" : "حالت تاریک"}
</Button>
</div>
</>
);
return (
<div className="flex min-h-screen bg-[var(--canvas)]">
<aside className="hidden w-64 shrink-0 flex-col border-l border-[var(--border)] bg-[var(--surface)] p-4 lg:flex">
{sidebar}
</aside>
{mobileOpen ? (
<div className="fixed inset-0 z-50 lg:hidden">
<button
type="button"
className="absolute inset-0 bg-black/40"
onClick={() => setMobileOpen(false)}
aria-label="بستن"
/>
<aside className="absolute right-0 top-0 flex h-full w-72 flex-col border-l border-[var(--border)] bg-[var(--surface)] p-4 shadow-lg">
<button
type="button"
className="mb-2 self-start rounded-lg p-2"
onClick={() => setMobileOpen(false)}
aria-label="بستن منو"
>
<X className="h-5 w-5" />
</button>
{sidebar}
</aside>
</div>
) : null}
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex items-center gap-2 border-b border-[var(--border)] bg-[var(--surface)] px-4 py-3 lg:hidden">
<Button variant="ghost" size="sm" onClick={() => setMobileOpen(true)} aria-label="منو">
<Menu className="h-5 w-5" />
</Button>
<span className="font-medium text-secondary">{PAYMENT_PORTAL.label}</span>
</header>
<main className="flex-1 p-4 md:p-6">{children}</main>
</div>
</div>
);
}

View File

@ -0,0 +1,36 @@
"use client";
import { cn } from "@/lib/utils";
const COLORS: Record<string, string> = {
paid: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
recorded: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
active: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
enabled: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
healthy: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
pending: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
pending_review: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
redirect_issued: "bg-sky-500/15 text-sky-700 dark:text-sky-300",
processing: "bg-sky-500/15 text-sky-700 dark:text-sky-300",
draft: "bg-slate-500/15 text-slate-700 dark:text-slate-300",
failed: "bg-rose-500/15 text-rose-700 dark:text-rose-300",
cancelled: "bg-slate-500/15 text-slate-600 dark:text-slate-400",
expired: "bg-slate-500/15 text-slate-600 dark:text-slate-400",
suspended: "bg-orange-500/15 text-orange-700 dark:text-orange-300",
closed: "bg-slate-500/15 text-slate-600 dark:text-slate-400",
disabled: "bg-slate-500/15 text-slate-600 dark:text-slate-400",
};
export function PaymentStatusChip({ status }: { status?: string | null }) {
const key = (status || "unknown").toLowerCase();
return (
<span
className={cn(
"inline-flex rounded-md px-2 py-0.5 text-xs font-medium",
COLORS[key] || "bg-[var(--surface-muted)] text-[var(--muted)]"
)}
>
{status || "—"}
</span>
);
}

View File

@ -0,0 +1,41 @@
"use client";
import { AuthGuard } from "@/components/AuthGuard";
import { useMe } from "@/hooks/useMe";
import { useRouter, usePathname } from "next/navigation";
import { useEffect } from "react";
import { LoadingState, ErrorState } from "@/components/ds";
import { PaymentPortalShell } from "@/modules/payment/components/PaymentPortalShell";
function PortalGate({ children }: { children: React.ReactNode }) {
const { me, loading, error, reload } = useMe();
const router = useRouter();
useEffect(() => {
if (loading) return;
if (me?.onboarding_required) router.replace("/onboarding");
}, [me, loading, router]);
if (loading) return <LoadingState label="در حال آماده‌سازی تربت‌پی…" />;
if (error) return <ErrorState message={error} onRetry={reload} />;
if (!me?.current_tenant_id) {
return <ErrorState message="برای استفاده از تربت‌پی workspace فعال لازم است." />;
}
return <PaymentPortalShell>{children}</PaymentPortalShell>;
}
export function PaymentRootLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const isHub = pathname === "/payment/hub";
if (isHub) {
return <AuthGuard>{children}</AuthGuard>;
}
return (
<AuthGuard>
<PortalGate>{children}</PortalGate>
</AuthGuard>
);
}

View File

@ -0,0 +1,27 @@
export const PAYMENT_VIEW = "payment.view";
export const PAYMENT_MANAGE = "payment.manage";
export const PAYMENT_AUDIT_VIEW = "payment.audit.view";
export const PAYMENT_SETTINGS_MANAGE = "payment.settings.manage";
export const PAYMENT_PSP_VIEW = "payment.psp_connections.view";
export const PAYMENT_PSP_MANAGE = "payment.psp_connections.manage";
export const PAYMENT_MERCHANT_VIEW = "payment.merchant_accounts.view";
export const PAYMENT_MERCHANT_MANAGE = "payment.merchant_accounts.manage";
export const PAYMENT_REQUEST_VIEW = "payment.requests.view";
export const PAYMENT_REQUEST_CREATE = "payment.requests.create";
export const PAYMENT_TX_VIEW = "payment.transactions.view";
export const PAYMENT_LEDGER_VIEW = "payment.ledger.view";
export const ALL_PAYMENT_PERMISSIONS = [
PAYMENT_VIEW,
PAYMENT_MANAGE,
PAYMENT_AUDIT_VIEW,
PAYMENT_SETTINGS_MANAGE,
PAYMENT_PSP_VIEW,
PAYMENT_PSP_MANAGE,
PAYMENT_MERCHANT_VIEW,
PAYMENT_MERCHANT_MANAGE,
PAYMENT_REQUEST_VIEW,
PAYMENT_REQUEST_CREATE,
PAYMENT_TX_VIEW,
PAYMENT_LEDGER_VIEW,
] as const;

View File

@ -0,0 +1,158 @@
import type { LucideIcon } from "lucide-react";
import {
LayoutDashboard,
CreditCard,
Building2,
Settings,
Shield,
Activity,
BarChart3,
Wallet,
Plug,
FileText,
BookOpen,
KeyRound,
Gauge,
ScrollText,
Network,
Eye,
} from "lucide-react";
export type PortalNavItem = { href: string; label: string; bundle?: string | null };
export type PortalNavGroup = {
id: string;
label: string;
icon: LucideIcon;
href?: string;
bundle?: string | null;
items?: PortalNavItem[];
};
export const PAYMENT_PORTAL = {
id: "payment" as const,
label: "تربت‌پی",
description: "پلتفرم پرداخت سازمانی — BYO-PSP و پذیرنده",
basePath: "/payment",
icon: CreditCard,
};
export const PAYMENT_NAV: PortalNavGroup[] = [
{
id: "dashboards",
label: "داشبوردها",
icon: LayoutDashboard,
items: [
{ href: "/payment/dashboard", label: "داشبورد اجرایی" },
{ href: "/payment/overview", label: "نمای کلی" },
{ href: "/payment/kpis", label: "شاخص‌های کلیدی" },
],
},
{
id: "operations",
label: "عملیات پرداخت",
icon: Wallet,
items: [
{ href: "/payment/requests", label: "درخواست‌های پرداخت" },
{ href: "/payment/transactions", label: "تراکنش‌ها" },
{ href: "/payment/ledger", label: "دفتر کل" },
{ href: "/payment/callbacks", label: "کال‌بک‌ها" },
{ href: "/payment/verification", label: "تأیید پرداخت" },
],
},
{
id: "psp",
label: "درگاه‌ها (PSP)",
icon: Plug,
bundle: "payment.byo_psp.basic",
items: [
{ href: "/payment/psp", label: "مدیریت درگاه", bundle: "payment.byo_psp.basic" },
{ href: "/payment/psp/health", label: "سلامت درگاه", bundle: "payment.byo_psp.basic" },
{ href: "/payment/providers", label: "تخصیص ارائه‌دهنده" },
],
},
{
id: "merchants",
label: "پذیرندگان",
icon: Building2,
bundle: "payment.torbat_pay.merchant",
items: [
{ href: "/payment/merchants", label: "حساب‌ها", bundle: "payment.torbat_pay.merchant" },
{ href: "/payment/merchants/approvals", label: "تأییدها", bundle: "payment.torbat_pay.merchant" },
],
},
{
id: "licensing",
label: "مجوز و باندل",
icon: KeyRound,
items: [
{ href: "/payment/bundles", label: "باندل‌ها" },
{ href: "/payment/feature-toggles", label: "کلیدهای ویژگی" },
{ href: "/payment/capabilities", label: "قابلیت‌ها" },
],
},
{
id: "ops",
label: "عملیات پلتفرم",
icon: Activity,
items: [
{ href: "/payment/monitoring", label: "مانیتورینگ" },
{ href: "/payment/metrics", label: "متریک‌ها" },
{ href: "/payment/audit", label: "حسابرسی" },
{ href: "/payment/settings", label: "تنظیمات" },
],
},
{
id: "dev",
label: "توسعه‌دهنده",
icon: Network,
items: [
{ href: "/payment/api-explorer", label: "کاوشگر API" },
{ href: "/payment/openapi", label: "OpenAPI" },
],
},
];
export function navForPayment() {
return PAYMENT_NAV;
}
export function filterNavByBundles(
nav: PortalNavGroup[],
activeBundles: string[] | undefined
) {
const bundles = new Set(activeBundles ?? []);
return nav
.map((g) => {
if (g.bundle && !bundles.has(g.bundle)) return null;
const items = g.items?.filter((i) => !i.bundle || bundles.has(i.bundle));
if (g.items && (!items || items.length === 0)) return null;
return { ...g, items };
})
.filter(Boolean) as PortalNavGroup[];
}
export const MVP_ROUTE_HREFS = [
"/payment/hub",
"/payment/dashboard",
"/payment/overview",
"/payment/kpis",
"/payment/transactions",
"/payment/ledger",
"/payment/psp",
"/payment/psp/health",
"/payment/providers",
"/payment/merchants",
"/payment/merchants/approvals",
"/payment/requests",
"/payment/callbacks",
"/payment/verification",
"/payment/audit",
"/payment/bundles",
"/payment/feature-toggles",
"/payment/capabilities",
"/payment/settings",
"/payment/metrics",
"/payment/monitoring",
"/payment/api-explorer",
"/payment/openapi",
] as const;

Some files were not shown because too many files have changed in this diff Show More