livrare website cu erp si crm

This commit is contained in:
DP Software services 2026-07-28 14:47:28 +03:00
parent 28773e3a72
commit 5c7bf7c295
257 changed files with 31929 additions and 0 deletions

5
website/.dockerignore Normal file
View file

@ -0,0 +1,5 @@
node_modules
.next
.git
*.log
.env.local

61
website/.env.example Normal file
View file

@ -0,0 +1,61 @@
# ============================================================================
# Website DiDi — variabile de mediu (TEMPLATE)
# Copiaza in .env.production si completeaza. .env.production NU se comite in git.
#
# Stratul de integrare cu backend-ul DiDi e comutabil DOAR din env:
# - mod DEMO: mock-server-ul din pachetul de integrare (mock-server.js, :4000)
# - mod REAL: host-urile platformei DiDi (livrate de operatorul platformei)
# Codul ramane neschimbat la comutare.
# ============================================================================
# ── ERPNext ─────────────────────────────────────────────────────────────────
# server-side (DNS docker pe didi-network)
ERPNEXT_API_URL=http://didi-erpnext:8080
# browser-facing (linkuri/PDF directe din browserul userului)
NEXT_PUBLIC_ERPNEXT_URL=http://<HOST>:8080
# generate de didi_custom.setup_website_integration.execute (user website_api@didi-erp.local)
ERPNEXT_API_KEY=<api_key>
ERPNEXT_API_SECRET=<api_secret>
# ── Keycloak / Auth.js (SSO utilizatori) ────────────────────────────────────
# ⚠️ Realm-ul real e `didi-clients` (NU `didi-website` — acela nu exista).
# Client OIDC confidential, livrat de operatorul platformei.
AUTH_KEYCLOAK_ID=didi-website-server
AUTH_KEYCLOAK_SECRET=<client_secret>
AUTH_KEYCLOAK_ISSUER=https://<KEYCLOAK_HOST>/auth/realms/didi-clients
AUTH_SECRET=<genereaza cu: openssl rand -base64 32>
AUTH_TRUST_HOST=true
AUTH_URL=http://<HOST>:3000
# doar pe medii de test cu certificat self-signed; se SCOATE la certificat valid
#NODE_TLS_REJECT_UNAUTHORIZED=0
# ── Backend DiDi (framework + agent) ────────────────────────────────────────
# DEMO: DIDI_FRAMEWORK_URL=http://localhost:4000
# DIDI_API_URL=http://localhost:4000/api
DIDI_FRAMEWORK_URL=http://<BACKEND_HOST>:<PORT_FRAMEWORK>
DIDI_API_URL=http://<BACKEND_HOST>:<PORT_AGENT>/api
# ── Cont de serviciu M2M (alocare pachete dupa plata; realm didi-admins) ────
# DEMO: DIDI_ADMIN_TOKEN_URL=http://localhost:4000/realms/didi-admins/protocol/openid-connect/token
DIDI_ADMIN_TOKEN_URL=https://<KEYCLOAK_HOST>/auth/realms/didi-admins/protocol/openid-connect/token
DIDI_ADMIN_CLIENT_ID=didi-website-m2m
DIDI_ADMIN_CLIENT_SECRET=<client_secret M2M>
# id-urile planurilor din backend pentru abonamente (implicit 3=paid, 6=enterprise)
#DIDI_PLAN_ID_PAID=3
#DIDI_PLAN_ID_ENTERPRISE=6
# ── Stripe ──────────────────────────────────────────────────────────────────
# chei TEST pentru dezvoltare; chei LIVE la productie (dashboard.stripe.com/apikeys)
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_SECRET_KEY=sk_test_...
# OBLIGATORIU — validarea semnaturii webhook (dashboard.stripe.com/webhooks)
STRIPE_WEBHOOK_SECRET=whsec_...
# price id-urile abonamentelor (Stripe Dashboard > Products)
STRIPE_PRICE_PAID_MONTHLY=price_...
STRIPE_PRICE_PAID_YEARLY=price_...
STRIPE_PRICE_ENTERPRISE_MONTHLY=price_...
STRIPE_PRICE_ENTERPRISE_YEARLY=price_...
# ── Site ────────────────────────────────────────────────────────────────────
NEXT_PUBLIC_SITE_URL=http://<HOST>:3000
NEXT_PUBLIC_DEFAULT_LOCALE=ro

6
website/.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
node_modules/
.next/
*.log
.DS_Store
.env.production
.env.local

151
website/.gitlab-ci.yml Normal file
View file

@ -0,0 +1,151 @@
# DiDi Website (Next.js 16) — GitLab CI/CD
# =========================================
stages:
- prepare
- security
- lint
- test
- build
- deploy
- release
variables:
NODE_ENV: "test"
npm_config_cache: "$CI_PROJECT_DIR/.npm"
.node-cache: &node-cache
cache:
key: node-${CI_COMMIT_REF_SLUG}
paths:
- .npm/
- node_modules/
policy: pull-push
install:
stage: prepare
image: node:20-slim
<<: *node-cache
script:
- npm ci --prefer-offline
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_BRANCH == "staging"
security:audit:
stage: security
image: node:20-slim
needs: [install]
script:
- npm audit --audit-level=moderate || true
allow_failure: true
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
security:gitleaks:
stage: security
image:
name: zricethezav/gitleaks:latest
entrypoint: [""]
script:
- gitleaks detect --source . --no-banner --report-format json --report-path gitleaks-report.json
artifacts:
when: on_failure
paths: [gitleaks-report.json]
expire_in: 1 month
allow_failure: true
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
lint:
stage: lint
image: node:20-slim
needs: [install]
<<: *node-cache
script:
- npx tsc --noEmit
- npm run lint
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
test:
stage: test
image: node:20-slim
needs: [install]
<<: *node-cache
script:
- npm test -- --run --coverage 2>&1 || echo "tests pending — add Vitest suite"
allow_failure: true
artifacts:
when: always
paths: [coverage/]
expire_in: 1 week
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
build:
stage: build
image: node:20-slim
needs: [lint]
<<: *node-cache
script:
- npm run build
artifacts:
paths: [.next/]
expire_in: 1 week
rules:
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_BRANCH == "staging"
build:docker:
stage: build
needs: [build]
script:
- docker build -t didi-website:${CI_COMMIT_SHORT_SHA} -t didi-website:latest .
rules:
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_BRANCH == "staging"
tags: [shell, docker]
deploy:staging:
stage: deploy
needs: [build:docker]
script:
- docker compose up -d --force-recreate didi-website
environment:
name: staging
url: https://website.example.com
rules:
- if: $CI_COMMIT_BRANCH == "staging"
when: manual
tags: [shell, docker]
deploy:prod:
stage: deploy
needs: [build:docker]
script:
- docker compose up -d --force-recreate didi-website
environment:
name: production
url: https://didi365.eu
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
tags: [shell, docker]
release:
stage: release
image: registry.gitlab.com/gitlab-org/release-cli:latest
script:
- echo "Release ${CI_COMMIT_TAG}"
release:
tag_name: '$CI_COMMIT_TAG'
name: 'Release $CI_COMMIT_TAG'
description: 'Automated release for $CI_COMMIT_TAG'
rules:
- if: $CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+$/

279
website/CONFIGURATION.md Normal file
View file

@ -0,0 +1,279 @@
# Configurare Website DiDi
Variabile mediu complete, integrari externe, credentiale.
## Fisier `.env.production`
Localizat la `~/website/.env.production`. NU se commiteaza.
### Variabile complete
```env
# === ERPNext ===
# URL public (afisat in browser) - pentru link-uri, redirect-uri
NEXT_PUBLIC_ERPNEXT_URL=http://localhost:8080
# URL intern (din container) - pentru server-side API calls
ERPNEXT_API_URL=http://didi-erpnext:8080
# API token: ERPNext > User > API Access
ERPNEXT_API_KEY=85e12d367334775
ERPNEXT_API_SECRET=910a2dfda16d2af
# === Keycloak / Auth.js ===
AUTH_KEYCLOAK_ID=didi-website-server
AUTH_KEYCLOAK_SECRET=fmpupO7GkBWEy42wKkgEWIV8NcOuiK2d
AUTH_KEYCLOAK_ISSUER=https://<KEYCLOAK_HOST>/auth/realms/didi-clients
# Auth.js secret (>= 32 chars random) - openssl rand -base64 32
AUTH_SECRET=didi-nextauth-secret-change-in-production-2026
# Trust X-Forwarded-* headers (true cand e in spatele unui reverse proxy)
AUTH_TRUST_HOST=true
# URL pentru callback - acelasi cu cum acceseaza userul
AUTH_URL=http://<HOST>:3000
# === DiDi Platform API ===
# In mod DEMO ambele arata spre mock-server.js (http://localhost:4000[/api]).
# Agent V3 (analize)
DIDI_API_URL=http://didi-agent-v3:24803/api
# Framework (credite, profil user, register, cataloage, alocare pachete)
DIDI_FRAMEWORK_URL=http://didi-framework:3005
# === DiDi M2M (alocare pachete dupa plata; realm didi-admins) ===
# Flux in 3 pasi dupa confirmarea platii Stripe (vezi src/lib/didi-backend.ts):
# token client_credentials -> GET /api/admin/users?search=<email> (campul `id`)
# -> PUT /api/admin/users/{id}/subscription cu creditsRemained ABSOLUT.
# Clientul M2M trebuie sa aiba rol realm `admin` (altfel 403).
DIDI_ADMIN_TOKEN_URL=https://<KEYCLOAK_HOST>/auth/realms/didi-admins/protocol/openid-connect/token
DIDI_ADMIN_CLIENT_ID=didi-website-m2m
DIDI_ADMIN_CLIENT_SECRET=<client_secret M2M>
# id-urile planurilor backend pentru abonamente (implicit 3=paid, 6=enterprise)
#DIDI_PLAN_ID_PAID=3
#DIDI_PLAN_ID_ENTERPRISE=6
# === Stripe ===
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxx
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
# Stripe price IDs (subscription recurring)
STRIPE_PRICE_PAID_MONTHLY=price_xxx
STRIPE_PRICE_PAID_YEARLY=price_xxx
STRIPE_PRICE_ENTERPRISE_MONTHLY=price_xxx
STRIPE_PRICE_ENTERPRISE_YEARLY=price_xxx
# === Site ===
NEXT_PUBLIC_SITE_URL=http://<HOST>:3000
NEXT_PUBLIC_DEFAULT_LOCALE=ro
```
### IMPORTANT: NEXT_PUBLIC_* vs server-side
Next.js variabilele cu prefix `NEXT_PUBLIC_*` sunt **baked into JavaScript bundle la build**. Pentru a le schimba in productie e nevoie de REBUILD container.
Variabilele fara prefix sunt server-side - se pot schimba prin restart (env_file reload), fara rebuild.
| Schimbare | Actiune |
|-----------|---------|
| `NEXT_PUBLIC_*` | Rebuild + restart |
| Restul | Restart |
## ERPNext integration
### API token
```bash
# Generare token in ERPNext
docker exec didi-erpnext bash -c '
cd /home/frappe/frappe-bench &&
bench --site didi-erp execute frappe.core.doctype.user.user.generate_keys --args "[\"Administrator\"]"
'
# Output: {"api_key": "xxx", "api_secret": "yyy"}
```
Pune in `.env.production` la `ERPNEXT_API_KEY` + `ERPNEXT_API_SECRET`.
### Permisiuni
Recomandat: NU folosi Administrator. Creeaza un user dedicat:
```bash
docker exec didi-erpnext bash -c '
cd /home/frappe/frappe-bench &&
bench --site didi-erp add-user website_api@didi.local \
--first-name Website \
--last-name API \
--send-welcome-email 0
'
```
Apoi din UI: User > website_api > Roles > Adauga "Website Integration", "Sales User", "Customer Reader". Apoi genereaza API keys pentru acest user.
## Keycloak integration
### Realm `didi-clients`
Setting | Value
---|---
Realm name | `didi-clients`
Login with email | DA
Email as username | DA
Registration | DA (clienti se inregistreaza singuri)
Reset password | DA
Brute force protection | DA
Access token lifespan | 10 min
SSO session max | 24 ore
### Client `didi-website-server`
Setting | Value
---|---
Client ID | `didi-website-server`
Client Type | Confidential
Standard flow | Enabled
Direct access grants | Enabled
Service accounts | Enabled (pentru server-to-server)
Valid redirect URIs | `http://<HOST>:3000/*`, `https://didi365.eu/*`
Web origins | `http://<HOST>:3000`, `https://didi365.eu`, `+`
Client Secret | (in `AUTH_KEYCLOAK_SECRET`)
### Mapping pentru email/roles
In client > Client scopes > Dedicated, adauga mappers:
- `email` (Mapper Type: User Property, Property: email, Token claim name: email)
- `realm-roles` (built-in, vor fi propagate)
## Stripe integration
### Test mode
Foloseste cheile `pk_test_*` si `sk_test_*` pentru dezvoltare. Carduri test:
- `4242 4242 4242 4242` - success
- `4000 0000 0000 9995` - declined (insufficient funds)
- Orice CVC + data viitoare
### Webhook
Endpoint: `https://didi.tld/api/webhooks/stripe`
Events de subscris:
- `checkout.session.completed` (one-time + first subscription cycle)
- `customer.subscription.created`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `invoice.paid` / `invoice.payment_succeeded` (recurring billing)
- `invoice.payment_failed`
Signing secret: Dashboard Stripe > Webhooks > [endpoint] > "Signing secret" > `STRIPE_WEBHOOK_SECRET`.
### Idempotency
Toate evenimentele sunt scrise in `tabPayment Log` cu `stripe_session_id` UNIQUE. Daca acelasi event vine de 2 ori, al doilea esueaza silentios la INSERT (constraint violation) - flow garantat o-singura-data.
## DiDi platform integration
### Agent V3 (analize)
```env
DIDI_API_URL=http://didi-agent-v3:24803/api
```
Endpoints folosite:
- `POST /v3/techniques/analyze` (cu Bearer JWT)
- `POST /v3/ai-tampered/analyze`
- `POST /v3/claims/analyze`
- `POST /v3/domain/analyze`
- `POST /v3/media/upload` (multipart, pentru imagini/audio/video)
- `GET /v3/pipeline/{session_id}/queue-status`
- `GET /v3/pipeline/{session_id}/result`
- `GET /v3/pipeline/history?user_id=X` (istoric analize)
Auth: Bearer JWT obtinut din Keycloak (din session-token).
### Framework (credite)
```env
DIDI_FRAMEWORK_URL=http://didi-framework:3005
```
Endpoints folosite:
- `GET /api/auth/credits` - summary credite curente user
- `GET /api/auth/me` - profil complet user
Auth: Bearer JWT.
## i18n
Limbi suportate: `ro` (default) + `en`.
Fisiere: `src/i18n/ro.json`, `src/i18n/en.json`.
Switcher in navbar: `src/components/layout/Navbar.tsx`.
Pentru a adauga limba noua:
1. Copiaza `ro.json` -> `es.json` (sau ce vrei)
2. Tradu valorile
3. Adauga in `src/i18n/index.ts` la lista `SUPPORTED_LOCALES`
4. Adauga in Navbar la selector
## Sesiuni Auth.js
Sesiunile sunt **JWT (stateless)** - nu se stocheaza in DB. Cookies setate:
| Cookie | Continut | Securitate |
|--------|----------|------------|
| `authjs.session-token` | JWT semnat cu AUTH_SECRET | HttpOnly, SameSite=Lax |
| `authjs.csrf-token` | CSRF protection | HttpOnly, SameSite=Lax |
| `authjs.callback-url` | URL post-login | HttpOnly, SameSite=Lax |
| `authjs.pkce.code_verifier` | PKCE pentru OIDC flow | HttpOnly, scurt-trait |
Pentru productie cu HTTPS, schimba `useSecureCookies: true` in `src/lib/auth.ts`.
## SEO
`src/app/sitemap.ts` + `src/app/robots.ts` genereaza automat:
- `/sitemap.xml` - toate paginile publice + rute dinamice
- `/robots.txt` - permite Google, blocheaza `/dashboard/*` + `/api/*`
Open Graph + Twitter cards sunt setate in `src/app/layout.tsx`.
## GDPR
Cookie consent: `src/components/ui/CookieConsent.tsx`. 3 categorii:
- Necesare (intotdeauna ON, NextAuth/CSRF)
- Functionale (preferinte limba)
- Analitice (Google Analytics - daca adaugi)
Drepturi self-service in `/dashboard/profile`:
- Acces (Art. 15) - vezi datele tale in profile
- Rectificare (Art. 16) - editezi datele
- Portabilitate (Art. 20) - `/api/gdpr/export`
- Stergere (Art. 17) - `/api/gdpr/delete` (cu retentie fiscala 10 ani RO)
## Securitate
Headers HTTP setate de Next.js default:
- `Strict-Transport-Security` (la HTTPS)
- `X-Frame-Options: DENY`
- `X-Content-Type-Options: nosniff`
Pentru CSP (Content Security Policy), edit `next.config.ts`:
```typescript
headers: async () => [{
source: '/(.*)',
headers: [
{ key: 'Content-Security-Policy', value: "default-src 'self'; ..." }
]
}]
```
## Logging
In dev: `console.log` -> stdout container (`docker logs`).
In productie: recomandare adaugare `pino` cu transport spre Datadog/Loki/etc.
Erorile critice (Auth, Stripe webhook, fulfillment) sunt logate cu `console.error` si pot fi colectate via Docker logs sau via tool extern.

45
website/Dockerfile Normal file
View file

@ -0,0 +1,45 @@
# ================================
# Stage 1: Development
# ================================
FROM node:20-alpine AS dev
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]
# ================================
# Stage 2: Build
# ================================
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ================================
# Stage 3: Production
# ================================
FROM node:20-alpine AS prod
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/next.config.ts ./next.config.ts
EXPOSE 3000
CMD ["npm", "start"]

322
website/INSTALL.md Normal file
View file

@ -0,0 +1,322 @@
# Instalare Website DiDi de la 0
Build complet pentru website-ul Next.js, integrat cu ERPNext + Stripe + Keycloak.
## Prerequisite
### Software pe host
```bash
docker --version # >= 24.0
docker compose version # >= v2
```
NU e nevoie de Node.js pe host - se construieste in container.
### Resurse
- 2 GB RAM
- 5 GB spatiu disc
- Port 3000 liber
### Retea Docker
Aceeasi retea cu ERP/CRM:
```bash
docker network create didi-network 2>/dev/null || true
```
### Servicii dependinte (trebuie sa ruleze inainte)
- **ERPNext** (`didi-erpnext:8080`) - vezi `erp_crm/INSTALL.md`
- **Keycloak** (`didi-keycloak:8080` sau extern)
- **Stripe** cont (cheile API in .env.production)
- **DiDi Platform** (agent-v3) sau accesibil prin URL
Daca rulezi separat, website-ul porneste oricum dar paginile auth/checkout vor da erori.
## Build automat
```bash
cd ~/website
bash build-from-zero.sh
```
Scriptul:
1. Verifica `.env.production` (creeaza-l din template daca lipseste)
2. Build imagine Docker (multi-stage: node:20-alpine -> next build -> prod)
3. Start container
4. Health check pe http://localhost:3000
## Build manual
### Pas 1. `.env.production`
Editeaza `~/website/.env.production`. Variabilele critice:
```env
# ERPNext (intern via Docker DNS)
NEXT_PUBLIC_ERPNEXT_URL=http://localhost:8080
ERPNEXT_API_URL=http://didi-erpnext:8080
ERPNEXT_API_KEY=<token API key>
ERPNEXT_API_SECRET=<token secret>
# Keycloak
AUTH_KEYCLOAK_ID=didi-website-server
AUTH_KEYCLOAK_SECRET=<secret>
AUTH_KEYCLOAK_ISSUER=https://<KEYCLOAK_HOST>/auth/realms/didi-clients
AUTH_SECRET=<random 32 chars>
AUTH_TRUST_HOST=true
AUTH_URL=http://<HOST>:3000
# DiDi API (intern)
DIDI_API_URL=http://didi-agent-v3:24803/api
DIDI_FRAMEWORK_URL=http://didi-framework:3005
# Stripe
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
# Stripe subscriptions
STRIPE_PRICE_PAID_MONTHLY=price_xxx
STRIPE_PRICE_PAID_YEARLY=price_xxx
STRIPE_PRICE_ENTERPRISE_MONTHLY=price_xxx
STRIPE_PRICE_ENTERPRISE_YEARLY=price_xxx
# Site
NEXT_PUBLIC_SITE_URL=http://<HOST>:3000
NEXT_PUBLIC_DEFAULT_LOCALE=ro
```
Pentru valori complete vezi [CONFIGURATION.md](./CONFIGURATION.md).
### Pas 2. Build + run
```bash
cd ~/website
docker compose up -d --build
```
Build-ul dureaza ~30 secunde (Next.js compile + tsc + static generation).
### Pas 3. Verificare
```bash
sleep 10
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000
# Trebuie sa fie 200
```
Browser: http://localhost:3000
## Configurare Keycloak (one-time)
Pe platforma reala, realm-ul utilizatorilor este **`didi-clients`** (issuer cu prefix `/auth`). Pentru dezvoltare locala (mod DEMO cu mock), creeaza un realm local cu acelasi nume:
```bash
KC_TOKEN=$(curl -s -X POST http://localhost:28080/realms/master/protocol/openid-connect/token \
-d "client_id=admin-cli&username=admin&password=admin123&grant_type=password" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
# Realm
curl -s -X POST http://localhost:28080/admin/realms \
-H "Authorization: Bearer $KC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"realm": "didi-clients",
"enabled": true,
"registrationAllowed": true,
"resetPasswordAllowed": true
}'
# Client confidential
curl -s -X POST http://localhost:28080/admin/realms/didi-clients/clients \
-H "Authorization: Bearer $KC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"clientId": "didi-website-server",
"secret": "<acelasi cu AUTH_KEYCLOAK_SECRET>",
"publicClient": false,
"redirectUris": ["http://<HOST>:3000/*"],
"webOrigins": ["http://<HOST>:3000"],
"standardFlowEnabled": true,
"directAccessGrantsEnabled": true
}'
# Test user
curl -s -X POST http://localhost:28080/admin/realms/didi-clients/users \
-H "Authorization: Bearer $KC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"username": "test@didi.local",
"email": "test@didi.local",
"firstName": "Test",
"lastName": "User",
"enabled": true,
"emailVerified": true,
"credentials": [{"type": "password", "value": "Test1234!", "temporary": false}]
}'
```
## Configurare Stripe (one-time)
### Pas 1. Cont Stripe
Creeaza cont la https://stripe.com si activeaza modul test.
### Pas 2. Cheia API
Dashboard > Developers > API keys > "Reveal test key"
- `pk_test_xxx` (publishable) -> `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`
- `sk_test_xxx` (secret) -> `STRIPE_SECRET_KEY`
### Pas 3. Produse one-time
In `src/lib/stripe.ts` exista o mapare `PRICE_MAP` cu 14 price IDs. Acestea trebuie sa existe in contul tau Stripe. Pentru a le crea automat:
```bash
docker exec -i didi-website node -e "
const Stripe = require('stripe').default || require('stripe');
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const ITEMS = [
['techniques-text', 'Techniques - Text', 5000],
['techniques-image', 'Techniques - Image', 7500],
// ... vezi PRICE_MAP pentru lista completa
];
(async () => {
for (const [key, name, amount] of ITEMS) {
const p = await stripe.products.create({ name, metadata: { service_key: key }});
const price = await stripe.prices.create({ product: p.id, unit_amount: amount, currency: 'ron' });
console.log(key, '->', price.id);
}
})();
"
```
### Pas 4. Produse recurente (subscriptii)
```bash
docker exec -i didi-website node -e "
const Stripe = require('stripe').default || require('stripe');
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const PLANS = [
{ key: 'paid-monthly', name: 'DiDi Paid - Lunar', amount: 9900, interval: 'month' },
{ key: 'paid-yearly', name: 'DiDi Paid - Anual', amount: 99900, interval: 'year' },
{ key: 'enterprise-monthly', name: 'DiDi Enterprise - Lunar', amount: 49900, interval: 'month' },
{ key: 'enterprise-yearly', name: 'DiDi Enterprise - Anual', amount: 499000, interval: 'year' },
];
(async () => {
for (const p of PLANS) {
const prod = await stripe.products.create({ name: p.name, metadata: { plan_key: p.key }});
const price = await stripe.prices.create({
product: prod.id, unit_amount: p.amount, currency: 'ron',
recurring: { interval: p.interval }
});
console.log(p.key, '->', price.id);
}
})();
"
```
Apoi pune ID-urile in `.env.production` la `STRIPE_PRICE_PAID_MONTHLY` etc.
### Pas 5. Webhook (la productie)
In dev local NU primesti webhook-uri Stripe (nu poate ajunge la masina ta).
La productie:
- Dashboard Stripe > Developers > Webhooks > Add endpoint
- URL: `https://didi.tld/api/webhooks/stripe`
- Events: `checkout.session.completed`, `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.paid`, `invoice.payment_failed`
- Copy signing secret in `STRIPE_WEBHOOK_SECRET`
Pentru dev local, foloseste Stripe CLI:
```bash
stripe listen --forward-to http://<HOST>:3000/api/webhooks/stripe
```
## Configurare ERPNext API key (one-time)
In ERPNext: Setup > User > Administrator > API Access > Generate Keys
Salveaza API Key + Secret in `.env.production`:
```env
ERPNEXT_API_KEY=85e12d367334775
ERPNEXT_API_SECRET=910a2dfda16d2af
```
## Dezvoltare locala
Daca vrei sa schimbi codul fara rebuild de docker:
```bash
cd ~/website
npm install
cp .env.production .env.local
# Edit .env.local pentru localhost-uri (ERPNEXT_API_URL=http://localhost:8080)
npm run dev
```
Acceseaza http://localhost:3000 (sau alt port daca 3000 e ocupat).
## Probleme frecvente
### Login: "Server error - problem with server configuration"
Verifica:
1. `AUTH_KEYCLOAK_ISSUER` e accesibil din container (curl din container)
2. `AUTH_SECRET` setat la 32+ caractere random
3. Realm + Client exista in Keycloak
4. `redirectUris` in client include URL-ul tau
```bash
docker exec didi-website wget -qO- https://<KEYCLOAK_HOST>/auth/realms/didi-clients/.well-known/openid-configuration | python3 -m json.tool
```
### Login redirecteaza la /login dupa Keycloak
Cookies corupte. Hard refresh (Ctrl+Shift+R) sau modă incognito.
### Stripe Checkout duce inapoi la /login
`NEXT_PUBLIC_SITE_URL` are alt host decat cel din browser. Asigura-te ca acceseaza acelasi host (ex: tot `<HOST>:3000`, nu mix de localhost/IP).
### Pagina `/dashboard/credits` arata 0
Userul Keycloak nu are inregistrare in `bos_sysadmin.internet_user`. Trebuie seedat:
```sql
-- PG: didiFramework DB
INSERT INTO bos_sysadmin.internet_user (internet_user_id, person_id, credits_remained, credits_spent, storage_limit_bytes)
VALUES (90000, 90000, 100, 0, 1073741824);
INSERT INTO bos_sysadmin.user_credential (internet_user_id, email, enrollment_type, cellular_phone_no, no_attempts_failed, subscription_status, activation_date, keycloak_id)
VALUES (90000, 'test@didi.local', 1, '', 0, 1, CURRENT_DATE, '<keycloak_uuid>');
```
Sau Auth.js auto-creeaza la primul login daca framework e configurat asa.
### Build esueaza cu "Module not found"
```bash
docker compose build --no-cache website
```
### Site afiseaza versiune veche dupa rebuild
```bash
docker compose down website
docker image rm website-website
docker compose up -d --build website
```
## Reset complet
```bash
cd ~/website
docker compose down
docker image rm website-website 2>/dev/null
bash build-from-zero.sh
```

198
website/OPERATIONS.md Normal file
View file

@ -0,0 +1,198 @@
# Operare Website DiDi
## Comenzi uzuale
### Status
```bash
docker compose ps
docker stats --no-stream didi-website
```
### Restart
```bash
docker compose restart website
# sau cu rebuild
docker compose up -d --build website
```
### Log-uri
```bash
# Live log
docker compose logs -f website
# Doar erori
docker compose logs website 2>&1 | grep -E "error|ERROR|FAIL"
# Ultimele 100 linii
docker compose logs --tail 100 website
```
### Restart dupa schimbare cod (rebuild)
```bash
cd ~/website
docker compose up -d --build website
# Verifica
docker logs --tail 10 didi-website
```
## Update env vars
### Schimbare cheie Stripe / SendGrid / etc.
1. Editeaza `.env.production`
2. Rebuild (env vars sunt baked in la build):
```bash
docker compose up -d --build website
```
Pentru variabilele `NEXT_PUBLIC_*` (folosite in client), trebuie REBUILD. Pentru variabile server-side, doar restart e suficient.
## Monitorizare
### Health check
```bash
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000
curl -s http://localhost:3000/api/auth/session | python3 -m json.tool
```
### Performance
```bash
# Time First Byte
curl -s -o /dev/null -w "TTFB: %{time_starttransfer}s\nTotal: %{time_total}s\n" http://localhost:3000
# Lighthouse (necesita Node/Chrome pe host - sau via Docker)
docker run --rm --network didi-network \
-v $(pwd):/out \
femtopixel/google-lighthouse \
http://didi-website:3000 \
--output html --output-path /out/lighthouse-report.html
```
## Troubleshooting
### Site nu raspunde
```bash
# 1. Container status
docker compose ps
# 2. Log-uri
docker compose logs --tail 50 website
# 3. Test conectivitate la dependinte (din container)
docker exec didi-website wget -qO- http://didi-erpnext:8080 | head -1
docker exec didi-website wget -qO- https://<KEYCLOAK_HOST>/auth/realms/didi-clients | head -c 100
```
### Auth nu functioneaza
```bash
# Verifica OIDC discovery
docker exec didi-website wget -qO- https://<KEYCLOAK_HOST>/auth/realms/didi-clients/.well-known/openid-configuration | python3 -m json.tool
# Verifica cookies Auth.js
# Browser DevTools > Application > Cookies > <HOST>
# Trebuie sa existe: authjs.csrf-token, authjs.session-token (dupa login)
```
### Stripe checkout nu mai redirecteaza dupa plata
Verifica `NEXT_PUBLIC_SITE_URL` in `.env.production` - trebuie sa fie acelasi host cu cel din browser.
```bash
docker exec didi-website env | grep -E "AUTH_URL|NEXT_PUBLIC_SITE"
```
### Webhook Stripe nu firea
Webhook-ul fireaza doar daca Stripe poate ajunge la endpoint-ul tau. In dev local:
```bash
# Stripe CLI (instaleaza separat)
stripe listen --forward-to http://<HOST>:3000/api/webhooks/stripe
# Test manual cu curl (simuleaza un event)
curl -X POST http://<HOST>:3000/api/webhooks/stripe \
-H "Content-Type: application/json" \
-d '{"type":"checkout.session.completed","data":{"object":{...}}}'
```
In productie verifica Stripe Dashboard > Webhooks > [endpoint] > "Activity feed" pentru status delivery.
### PDF rapoarte goale / corupte
Generarea PDF se face cu jsPDF in browser (pentru rapoarte analize) sau wkhtmltopdf in ERPNext (pentru facturi).
Pentru facturi - verifica `host_name` in ERPNext:
```bash
docker exec didi-erpnext bash -c '
cd /home/frappe/frappe-bench &&
bench --site didi-erp set-config -g host_name "http://didi-erpnext:8080"
'
```
Pentru rapoarte analize - DevTools > Console pentru erori jsPDF.
### Site nu trage continut din ERPNext CMS
```bash
# Test API direct
curl -s -H "Authorization: token <ERPNEXT_API_KEY>:<ERPNEXT_API_SECRET>" \
"http://localhost:8080/api/resource/Website%20Content?fields=[%22page_slug%22,%22section_key%22]&limit_page_length=5"
```
Daca 401, token-ul e gresit. Daca [], DB-ul nu are inregistrari (re-ruleaza `scripts/setup/04-populate-content.py` din erp_crm).
## Backup
Website-ul e stateless - tot ce conteaza e in:
- Git repo (codul)
- ERPNext DB (datele clientilor + facturile)
- Stripe (platile - irretrievable backup)
Pentru codul actual:
```bash
cd ~
tar czf website-backup-$(date +%Y%m%d).tar.gz \
--exclude='node_modules' \
--exclude='.next' \
website/
```
## Performance tuning
### Numar workers Next.js
Edit `Dockerfile` (sau env var pentru Next 16):
```dockerfile
ENV NEXT_PUBLIC_WORKERS=4
```
### Cache HTTP
In productie, pune un reverse proxy (nginx/Caddy) cu:
- Cache pe static assets (`_next/static/*`, `public/*`): max-age=31536000 immutable
- Cache pe pagini SSR cu revalidate (vezi `force-dynamic` / `revalidate`)
### Imagini
Public images sunt cache-uite implicit cu max-age=60 in Next 16. Pentru imagini hostate la utilizator (avatar etc.), foloseste `<Image>` cu `loader` + CDN.
## SLA
| Severitate | Descriere | Raspuns | Rezolvare |
|------------|-----------|---------|-----------|
| Critica | Site down / login nu functioneaza | 4 ore | 24 ore |
| Majora | Functionalitate importanta (checkout, dashboard) | 8 ore | 3 zile |
| Minora | UI, cosmetic, edge case | 24 ore | 10 zile |
Suport: office@clossers.com, zile lucratoare 09-18.

252
website/README.md Normal file
View file

@ -0,0 +1,252 @@
# DiDi Website (Next.js)
Componenta frontend a platformei DiDi - website public + dashboard client. Construit cu Next.js 16, integrat cu ERPNext (ERP/CRM), Stripe (plati), Keycloak (SSO) si platforma DiDi (analize AI).
## Cuprins
- [Arhitectura](#arhitectura)
- [Stack tehnic](#stack-tehnic)
- [Structura folder](#structura-folder)
- [Quickstart](#quickstart)
- [Pagini](#pagini)
- [API routes](#api-routes)
- [Integrari](#integrari)
- [Documentatie detaliata](#documentatie-detaliata)
## Arhitectura
```
Browser (client)
|
v
+-----------------------------+
| Website Next.js :3000 |
| (SSR + API routes) |
+-----+-------+-------+-------+
| | |
+-------+--+ +--+----+ +-+--------+
| ERPNext | |Stripe | | Keycloak |
| :8080 | | API | | :28080 |
| (REST) | | | | (OIDC) |
+----+-----+ +-------+ +----------+
|
(factura, customer, CRM)
|
DiDi platform API
(analize text/image/audio/video)
```
## Stack tehnic
| Componenta | Versiune |
|------------|----------|
| Next.js | 16.2.1 (App Router) |
| React | 19.2.4 |
| TypeScript | 5.x |
| Tailwind CSS | 4.x |
| NextAuth (Auth.js) | 5.0 beta |
| Stripe | 21.0 |
| Node.js | 20 (in container) |
## Structura folder
```
website/
├── README.md # Acest fisier
├── INSTALL.md # Build + deployment
├── OPERATIONS.md # Operare, log-uri, troubleshooting
├── CONFIGURATION.md # Env vars, integrari, credentiale
├── Dockerfile # Multi-stage: dev / builder / prod
├── docker-compose.yml # Stack de productie
├── .env.production # Configurare prod (NU se commiteaza)
├── package.json
├── tsconfig.json
├── next.config.ts
├── eslint.config.mjs
├── postcss.config.mjs
├── public/ # Static assets (clossersLogo2.png etc.)
├── src/
│ ├── app/ # App Router (Next.js 16)
│ │ ├── (public)/ # Pagini publice fara auth
│ │ │ ├── page.tsx # Homepage
│ │ │ ├── about/
│ │ │ ├── services/
│ │ │ ├── pricing/
│ │ │ ├── contact/
│ │ │ ├── privacy/
│ │ │ ├── terms/
│ │ │ └── layout.tsx # Navbar + Footer + Cookie consent
│ │ ├── (auth)/ # Login / Register
│ │ │ ├── login/
│ │ │ └── register/
│ │ ├── (dashboard)/dashboard/# Zona client autentificat
│ │ │ ├── page.tsx # Overview
│ │ │ ├── analiza/ # Run analiza
│ │ │ ├── analize/ # Istoric analize + rapoarte PDF
│ │ │ ├── achizitioneaza/ # Cumparare servicii
│ │ │ ├── checkout/success/ # Stripe success callback
│ │ │ ├── credits/ # Credite + consum (real-time)
│ │ │ ├── invoices/ # Facturi cu download PDF
│ │ │ ├── profile/ # Profil + GDPR (export, delete)
│ │ │ └── subscription/ # Abonament + upgrade/downgrade
│ │ ├── api/ # API routes (server-side)
│ │ │ ├── auth/[...nextauth]/ # Auth.js handler
│ │ │ ├── analyze/ # Analiza pipeline
│ │ │ ├── analysis-reports/
│ │ │ ├── analysis-report-pdf/
│ │ │ ├── checkout/ # Stripe Checkout Session
│ │ │ ├── subscribe/ # Stripe Subscription
│ │ │ ├── subscription/manage/ # Upgrade/downgrade/cancel
│ │ │ ├── customer/me/ # Get current user customer
│ │ │ ├── credits/ # Credite + history
│ │ │ ├── erp/[...path]/ # Proxy ERPNext
│ │ │ ├── invoice-pdf/ # Download factura PDF
│ │ │ ├── leads/ # Lead creation (contact form)
│ │ │ ├── webhooks/stripe/ # Stripe webhook handler
│ │ │ └── gdpr/ # Export + delete account
│ │ │ ├── export/
│ │ │ └── delete/
│ │ ├── layout.tsx # Root layout
│ │ ├── sitemap.ts
│ │ └── robots.ts
│ ├── components/ # React components
│ │ ├── layout/ # Navbar, Footer, DashboardSidebar
│ │ ├── forms/ # ContactForm
│ │ ├── providers/ # SessionProvider
│ │ └── ui/ # CookieConsent, SignOutButton
│ ├── i18n/ # Traduceri ro.json + en.json
│ ├── lib/ # Helpers server-side
│ │ ├── auth.ts # Auth.js config (Keycloak)
│ │ ├── erpnext.ts # ERPNext REST client
│ │ ├── stripe.ts # Stripe config + price map
│ │ ├── stripe-fulfillment.ts # Webhook fulfillment logic
│ │ ├── api.ts # Client API (browser)
│ │ ├── analysis-display.ts # Format rezultate analize
│ │ ├── analysis-report-pdf.ts# Generare PDF (jsPDF)
│ │ └── analysis-report-store.ts # Salvare PDF in ERPNext
│ ├── middleware.ts # Auth middleware (/dashboard/*)
│ └── types/ # TypeScript types
└── scripts/
└── archive/ # Scripturi generare doc-uri (one-off)
```
Documentatia de proiect (arhitectura, documentatia API, manuale, caiet de
sarcini) se livreaza separat, in dosarul de livrare — nu face parte din repo.
## Quickstart
Cere docker + docker compose v2 si reteaua `didi-network`:
```bash
docker network create didi-network 2>/dev/null || true
```
Build + start:
```bash
cd ~/website
cp .env.production.example .env.production # (daca exista, sau editeaza .env.production existent)
docker compose up -d --build
```
Acceseaza: http://localhost:3000
Pentru detalii, vezi [INSTALL.md](./INSTALL.md).
## Pagini
### Pagini publice (fara login)
| Ruta | Descriere |
|------|-----------|
| `/` | Homepage (continut din ERPNext CMS) |
| `/services` | Module AI DiDi (6 servicii) |
| `/about` | Despre Clossers + DiDi |
| `/pricing` | 14 servicii one-time + 4 abonamente recurente |
| `/contact` | Formular contact (creeaza Lead in ERPNext) |
| `/privacy` | Politica de confidentialitate (GDPR) |
| `/terms` | Termeni si conditii |
### Pagini auth
| Ruta | Descriere |
|------|-----------|
| `/login` | Redirect la Keycloak SSO |
| `/register` | Redirect la Keycloak registration |
### Pagini dashboard (auth obligatoriu)
| Ruta | Descriere |
|------|-----------|
| `/dashboard` | Overview - facturi recente, credite, achizitii |
| `/dashboard/analiza` | Selecteaza factura + ruleaza analiza |
| `/dashboard/analize` | Istoric analize + descarcare rapoarte PDF |
| `/dashboard/invoices` | Facturi emise + download PDF |
| `/dashboard/credits` | Credite disponibile + istoric consum |
| `/dashboard/achizitioneaza` | Servicii one-time + abonamente |
| `/dashboard/subscription` | Plan curent + upgrade/downgrade/cancel |
| `/dashboard/profile` | Profil + GDPR (export, delete) |
| `/dashboard/checkout/success` | Callback post-Stripe |
## API routes
### Public
| Endpoint | Metoda | Descriere |
|----------|--------|-----------|
| `/api/leads` | POST | Creeaza Lead in ERPNext din formular contact |
| `/api/webhooks/stripe` | POST | Webhook Stripe (events) |
### Autentificate
| Endpoint | Metoda | Descriere |
|----------|--------|-----------|
| `/api/auth/[...nextauth]` | GET/POST | Auth.js handler |
| `/api/analyze` | GET, POST | Lista analize + run + polling |
| `/api/checkout` | POST | Creeaza Stripe Checkout (one-time) |
| `/api/subscribe` | POST | Creeaza Stripe Subscription |
| `/api/subscription/manage` | POST | Upgrade/downgrade/cancel |
| `/api/customer/me` | GET | Datele Customer din ERPNext |
| `/api/credits` | GET | Credite + istoric |
| `/api/erp/[...path]` | * | Proxy autentificat catre ERPNext |
| `/api/invoice-pdf` | GET | Download factura PDF |
| `/api/analysis-reports` | GET, POST | List + salvare rapoarte |
| `/api/analysis-report-pdf` | GET | Download raport analiza PDF |
| `/api/gdpr/export` | GET | Export date utilizator (JSON) |
| `/api/gdpr/delete` | POST | Cerere stergere cont |
## Integrari
| Sistem | Protocol | Scop |
|--------|----------|------|
| ERPNext | REST API (token auth) | Facturi, customeri, lead-uri, CMS |
| Stripe | API + webhook | Plati one-time + recurente |
| Keycloak | OIDC | SSO (autentificare clienti) |
| DiDi platform | REST API (Bearer JWT) | Analize text/image/audio/video |
| SendGrid | (indirect prin ERPNext SMTP) | Email tranzactional |
## Functionalitati cheie
- Design responsive (desktop/tablet/mobile), WCAG 2.1 AA
- SEO (sitemap.xml, robots.txt, Open Graph)
- GDPR: cookie consent, export date, dreptul la stergere
- i18n RO + EN cu language switcher
- PDF generation (facturi + rapoarte analize)
- Webhook idempotency (Payment Log cu stripe_session_id unique)
## Documentatie detaliata
| Document | Scop |
|----------|------|
| [INSTALL.md](./INSTALL.md) | Build de la 0, dependinte, deployment |
| [OPERATIONS.md](./OPERATIONS.md) | Restart, log-uri, debug |
| [CONFIGURATION.md](./CONFIGURATION.md) | Env vars complete, integrari |
## Test user
Pentru testare:
- Email: `test@didi.local`
- Parola: `Test1234!`
- (creat in Keycloak local, realm `didi-clients` — acelasi nume ca pe platforma reala)
## Suport
Pentru intrebari tehnice: office@clossers.com

77
website/build-from-zero.sh Executable file
View file

@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Build de la 0 Website DiDi - one-shot installer
# Versiune 1.0, mai 2026
#
# Cerinte: docker, docker compose v2
# Reteaua Docker `didi-network` trebuie sa existe.
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$DIR"
log() { printf '\033[1;36m[%(%H:%M:%S)T]\033[0m %s\n' -1 "$*"; }
err() { printf '\033[1;31m[%(%H:%M:%S)T] ERROR:\033[0m %s\n' -1 "$*" >&2; exit 1; }
# ── Verificari ──────────────────────────────────────────────
command -v docker >/dev/null || err "docker nu e instalat"
docker compose version >/dev/null || err "docker compose v2 nu e instalat"
if ! docker network inspect didi-network >/dev/null 2>&1; then
log "Creez reteaua didi-network..."
docker network create didi-network
fi
# ── Verificare .env.production ──────────────────────────────
if [ ! -f .env.production ]; then
err ".env.production lipseste. Vezi CONFIGURATION.md pentru variabile."
fi
# Verifica variabilele critice
source .env.production 2>/dev/null || true
[ -n "${AUTH_KEYCLOAK_SECRET:-}" ] || err "AUTH_KEYCLOAK_SECRET nu e setat in .env.production"
[ -n "${AUTH_SECRET:-}" ] || err "AUTH_SECRET nu e setat in .env.production"
[ -n "${STRIPE_SECRET_KEY:-}" ] || err "STRIPE_SECRET_KEY nu e setat in .env.production"
[ -n "${ERPNEXT_API_KEY:-}" ] || err "ERPNEXT_API_KEY nu e setat in .env.production"
# Verifica ERPNext disponibil
log "Verific accesibilitate ERPNext..."
if ! curl -sf -o /dev/null --max-time 5 "${NEXT_PUBLIC_ERPNEXT_URL:-http://localhost:8080}"; then
log " ATENTIE: ERPNext nu raspunde la ${NEXT_PUBLIC_ERPNEXT_URL:-http://localhost:8080}"
log " Website-ul porneste oricum, dar paginile vor da erori la API calls catre ERPNext."
fi
# ── Build + start ───────────────────────────────────────────
log "Build imagine Docker (~30 sec)..."
docker compose build --quiet website 2>&1 | tail -3 || err "Build a esuat"
log "Pornire container..."
docker compose up -d website
# ── Health check ────────────────────────────────────────────
log "Astept ca website-ul sa fie ready..."
for i in $(seq 1 20); do
HTTP=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000 || echo "000")
if [ "$HTTP" = "200" ]; then
log " UP la http://localhost:3000"
break
fi
sleep 2
done
if [ "$HTTP" != "200" ]; then
log " Status: $HTTP - verifica log-urile cu: docker compose logs --tail 50 website"
exit 1
fi
# ── Quick smoke test ────────────────────────────────────────
log "Smoke test..."
for path in "/" "/about" "/pricing" "/contact" "/api/auth/session"; do
code=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:3000${path}")
printf " %-25s %s\n" "$path" "$code"
done
log "============================================="
log " Website DiDi UP: http://localhost:3000"
log " Test login: test@didi.local / Test1234!"
log "============================================="

View file

@ -0,0 +1,24 @@
services:
website:
build:
context: .
dockerfile: Dockerfile
target: prod
container_name: didi-website
restart: unless-stopped
ports:
- "127.0.0.1:3001:3000"
env_file:
- .env.production
networks:
- didi-network
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
networks:
didi-network:
external: true

18
website/eslint.config.mjs Normal file
View file

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

6
website/next-env.d.ts vendored Normal file
View file

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

7
website/next.config.ts Normal file
View file

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

6993
website/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

31
website/package.json Normal file
View file

@ -0,0 +1,31 @@
{
"name": "website",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@stripe/stripe-js": "^9.0.1",
"@tailwindcss/typography": "^0.5.19",
"jspdf": "^4.2.1",
"next": "16.2.1",
"next-auth": "^5.0.0-beta.30",
"react": "19.2.4",
"react-dom": "19.2.4",
"stripe": "^21.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.1",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View file

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

1
website/public/file.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
website/public/globe.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1 KiB

BIN
website/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

1
website/public/next.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View file

@ -0,0 +1,176 @@
"""Generate 3 documentation Word files for DiDi platform."""
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
from docx import Document
from docx.shared import Pt, Cm, RGBColor
BASE = "C:/Users/avedev/Desktop/Achizitii TOP/livrabile/didi"
def setup_doc():
doc = Document()
for s in doc.sections:
s.top_margin = Cm(2.5); s.bottom_margin = Cm(2); s.left_margin = Cm(2.5); s.right_margin = Cm(2.5)
style = doc.styles['Normal']; style.font.name = 'Calibri'; style.font.size = Pt(11)
style.paragraph_format.space_after = Pt(6); style.paragraph_format.line_spacing = 1.15
for lv, sz in [(1,16),(2,13),(3,11)]:
hs = doc.styles[f'Heading {lv}']; hs.font.name = 'Calibri'; hs.font.size = Pt(sz)
hs.font.color.rgb = RGBColor(0x0D,0x94,0x88); hs.font.bold = True
return doc
def tbl(doc, headers, rows):
t = doc.add_table(rows=1, cols=len(headers)); t.style = 'Light Grid Accent 1'
for i, h in enumerate(headers):
t.rows[0].cells[i].text = h
for p in t.rows[0].cells[i].paragraphs:
for r in p.runs: r.bold = True; r.font.size = Pt(9)
for rd in rows:
row = t.add_row()
for i, v in enumerate(rd):
row.cells[i].text = str(v)
for p in row.cells[i].paragraphs:
for r in p.runs: r.font.size = Pt(9)
def bl(doc, text):
p = doc.add_paragraph(style='List Bullet'); p.paragraph_format.space_after = Pt(3)
p.add_run(text).font.size = Pt(10)
def pa(doc, text, sz=11):
p = doc.add_paragraph(); p.add_run(text).font.size = Pt(sz)
# ═══ DOC 1: Manual Utilizare ═══
print("1. Manual_Utilizare_Website.docx...")
d = setup_doc()
d.add_heading('Manual de Utilizare - Website DiDi', level=1)
pa(d, 'Ghid complet pentru utilizatorii platformei DiDi de la Clossers.')
d.add_heading('1. Introducere', level=1)
pa(d, 'DiDi este o platforma digitala inteligenta pentru prevenirea si combaterea dezinformarii. Analizeaza automat continut media (text, imagini, audio, video) folosind inteligenta artificiala pentru a detecta dezinformarea si a verifica informatiile in timp real.')
d.add_heading('2. Pagini Publice', level=1)
for t, desc in [('Homepage','Prezentare platforma: hero, 4 capabilitati AI, cum functioneaza (4 pasi), cazuri de utilizare, statistici. Continut gestionat din CMS.'),('Servicii','6 module AI detaliate: Analiza Text, Detectie Deepfake, Fact-Checking, Evaluare Surse, Tehnici Manipulare, Monitorizare Media.'),('Pricing','Grila preturi: 4 categorii servicii x tipuri media (Text, Imagine, Audio, Video). Preturi in RON. Buton achizitie cu redirect Stripe.'),('Despre','Prezentare companie, misiune, proiect PNRR, tehnologie utilizata.'),('Contact','Formular (nume, email, mesaj) care creeaza automat Lead in CRM.'),('Privacy / Termeni','Politica GDPR si Termeni si Conditii, editabile din CMS.')]:
d.add_heading(t, level=2); pa(d, desc, 10)
d.add_heading('3. Autentificare', level=1)
bl(d,'Login prin SSO Keycloak - buton "Login" din navbar, redirect catre pagina centralizata')
bl(d,'Inregistrare - creare cont pe serverul de identitate, autentificare automata')
bl(d,'Sesiune JWT cu reinoire automata; deconectare prin buton "Sign out"')
d.add_heading('4. Dashboard Client', level=1)
for t, items in [('4.1 Overview',['Numar facturi emise si analize achizitionate','Tabel ultimele facturi (numar, data, suma, status)','Link achizitie serviciu']),('4.2 Analize',['Lista analize disponibile (achizitionate, neconsumate)','Selectare analiza → formular input (text sau URL)','Pornire analiza → procesare AI (pana la 2 minute)','Rezultate: scor risc, concluzie, tehnici manipulare, verificare afirmatii, detectie AI, evaluare sursa, viralitate','Buton "Descarca Raport PDF" cu toate rezultatele']),('4.3 Facturi',['Lista completa cu numar, data, suma, status','Download PDF (format "DiDi Invoice" personalizat)']),('4.4 Abonament',['Plan activ si functionalitati incluse','Free: 5 analize/luna | Paid: 100/luna | Enterprise: nelimitat']),('4.5 Profil',['Editare date: companie, cod fiscal','GDPR: export date CSV, solicitare stergere cont'])]:
d.add_heading(t, level=2)
for i in items: bl(d, i)
d.add_heading('5. Achizitie Serviciu', level=1)
for s in ['1. Pricing → selectare serviciu si tip media','2. Redirect Stripe Checkout (plata securizata PCI DSS)','3. Completare date card si plata','4. Confirmare → factura automata cu TVA 19%','5. Analiza disponibila in dashboard']: bl(d, s)
d.add_heading('6. Cookie Consent si Limba', level=1)
pa(d,'Banner cookie cu 3 categorii: Necesare (always on), Functionale, Analitice. Comutare limba RO/EN din navbar.')
d.save(f"{BASE}/Manual_Utilizare_Website.docx"); print(" OK")
# ═══ DOC 2: Documentatie API ═══
print("2. Documentatie_API.docx...")
d = setup_doc()
d.add_heading('Documentatie API - Platforma DiDi', level=1)
d.add_heading('1. Arhitectura si Autentificare', level=1)
pa(d,'3 categorii API: Website API Routes (Next.js), ERPNext REST API (proxy server-side), DiDi Analysis API (AI).')
bl(d,'Website: sesiune NextAuth (cookie httpOnly) prin Keycloak SSO')
bl(d,'ERPNext: API Key + Secret (server-side)')
bl(d,'DiDi API: Bearer token JWT din sesiunea Keycloak')
d.add_heading('2. Endpoint-uri Website', level=1)
tbl(d, ['Endpoint','Descriere','Request','Response'], [
['POST /api/checkout','Sesiune plata one-time','{ serviceKey }','{ url }'],
['POST /api/subscribe','Subscriptie recurenta','{ planKey }','{ url }'],
['GET /api/analyze','Lista analize disponibile','Auth required','{ data: [{invoice, component}] }'],
['POST /api/analyze','Trimitere analiza','{invoice, component, media, text/url}','{ data: {session_id} }'],
['GET /api/analyze?session_id=X','Polling rezultate','Auth required','{ data: {status, risk_score} }'],
['POST /api/leads','Creare lead CRM','{lead_name, email_id, notes}','{ data: {name} }'],
['GET /api/invoice-pdf?name=X','Download PDF factura','Auth required','PDF binary'],
['GET /api/customer/me','Date client','Auth required','{ data: {name, iam_role} }'],
['GET /api/analysis-reports','Lista rapoarte','Auth required','{ data: [...] }'],
['POST /api/analysis-reports','Salvare raport','{invoiceName, sessionId, result}','{ data: {name, pdfFile} }'],
['GET /api/analysis-report-pdf','Download PDF raport','?name=X','PDF binary'],
['POST /api/webhooks/stripe','Webhook Stripe','Stripe signature','{ received: true }'],
])
d.add_heading('2.1 Evenimente Webhook Stripe', level=2)
bl(d,'checkout.session.completed → Client + Factura + Payment Entry + Service Agreement')
bl(d,'customer.subscription.updated → update iam_role pe Customer')
bl(d,'customer.subscription.deleted → downgrade free_tier')
bl(d,'invoice.payment_failed → log in Payment Log')
d.add_heading('3. ERPNext DocTypes', level=1)
tbl(d, ['DocType','Descriere','Campuri'], [
['Customer','Clienti','customer_name, didi_user_id, iam_role, active_plan'],
['Sales Invoice','Facturi','customer, grand_total, status, items, taxes'],
['Lead','Lead-uri','lead_name, email_id, source, source_form, utm_*'],
['Website Content','CMS','page_slug, section_key, content_ro, content_en'],
['Service Agreement','Acorduri','customer, plan, status, acceptance_date'],
['Payment Log','Log plati','customer, event_type, status, stripe_session_id'],
['Analysis Report','Rapoarte','customer, sales_invoice, component, pdf_file, result_json'],
['Item','Servicii','item_code, item_name, standard_rate'],
['Subscription Plan','Planuri','plan_name, cost, billing_interval'],
])
d.add_heading('4. DiDi Analysis API', level=1)
tbl(d, ['Endpoint','Metoda','Descriere','Response'], [
['/v3/techniques/analyze','POST','Tehnici manipulare','risk_score, techniques_detected[]'],
['/v3/ai-tampered/analyze','POST','Detectie AI','ai_probability, verdict'],
['/v3/claims/analyze','POST','Fact-checking','claims[{claim, verdict}]'],
['/v3/domain/analyze','POST','Evaluare sursa','credibility_score, category'],
])
d.save(f"{BASE}/Documentatie_API.docx"); print(" OK")
# ═══ DOC 3: Ghid Admin ERPNext ═══
print("3. Ghid_Administrare_ERPNext.docx...")
d = setup_doc()
d.add_heading('Ghid de Administrare - ERPNext DiDi', level=1)
d.add_heading('1. Accesare si Navigare', level=1)
pa(d,'ERPNext se acceseaza prin browser. Autentificare cu contul Administrator. Sidebar cu 7 module:')
tbl(d, ['Modul','Continut'], [['Facturi','Sales Invoice, Payment Entry, TVA'],['Clienti','Customer, Service Agreement'],['CRM','Lead, Sales Stage, Lead Source'],['Servicii','Item, Subscription Plan'],['Plati','Payment Log, Analysis Report'],['Contabilitate','Account, Company, Supplier'],['Website CMS','Website Content, Email Template']])
d.add_heading('2. Facturi', level=1)
bl(d,'Create automat la plata Stripe. Serie: DIDI-INV-YYYY-#####')
bl(d,'TVA 19% automat. Print Format "DiDi Invoice" cu logo si date firma')
bl(d,'Statusuri: Draft → Submitted → Paid / Cancelled. Download PDF din lista.')
d.add_heading('3. Clienti', level=1)
tbl(d, ['Camp','Descriere'], [['customer_name','Numele clientului'],['didi_user_id','UUID Keycloak'],['iam_role','free_tier / paid_tier / enterprise_tier'],['active_plan','Cod plan activ'],['plan_activation_date','Data activarii']])
d.add_heading('4. CRM', level=1)
bl(d,'Lead-uri automate din formular contact website')
bl(d,'Pipeline: Lead → Calificat → Demo → Client')
bl(d,'UTM tracking: utm_source, utm_medium, utm_campaign')
d.add_heading('5. Servicii', level=1)
bl(d,'14 articole one-time: TECHNIQUES/AI_DETECTION/CLAIMS/SOURCE x TEXT/IMAGE/AUDIO/VIDEO/URL (50-200 RON)')
bl(d,'5 planuri: Free(0), Paid lunar(99)/anual(999), Enterprise lunar(499)/anual(4990) RON')
d.add_heading('6. Plati si Rapoarte', level=1)
bl(d,'Payment Log — tranzactii Stripe: status, suma, event_type, stripe_session_id')
bl(d,'Analysis Report — rapoarte AI cu PDF atasat, rezultat JSON complet')
d.add_heading('7. Contabilitate', level=1)
bl(d,'Plan conturi romanesc tradus. TVA 19% sablon automat.')
bl(d,'Furnizori: Hetzner (hosting), Stripe (plati), SendGrid (email)')
bl(d,'Rapoarte standard: Balanta, Profit si Pierdere, Registru General')
bl(d,'3 rapoarte custom: Dashboard Financiar, Raport CRM, Clienti per Plan')
d.add_heading('8. Website CMS', level=1)
pa(d,'24 intrari pe 6 pagini (homepage, services, about, contact, privacy, terms). Fiecare cu content_ro, content_en, image, extra_data JSON.')
d.add_heading('9. Notificari Email', level=1)
bl(d,'Factura Emisa — email automat la confirmare, cu PDF atasat')
bl(d,'Plata Confirmata — email la plata reusita')
bl(d,'Expirare Abonament — 7 zile inainte de expirare')
pa(d,'Email Account SendGrid configurat (necesita cheie API reala).')
d.add_heading('10. Service Agreement', level=1)
pa(d,'Acorduri automate la achizitie: client, plan, data acceptare, versiune termeni. Statusuri: Draft → Accepted → Expired.')
d.save(f"{BASE}/Ghid_Administrare_ERPNext.docx"); print(" OK")
print("\nAll 3 documents generated!")

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,875 @@
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
from docx import Document
from docx.shared import Inches, Pt, Cm, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.enum.section import WD_ORIENT
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
import datetime
doc = Document()
# ── Page margins ──
for section in doc.sections:
section.top_margin = Cm(2.5)
section.bottom_margin = Cm(2)
section.left_margin = Cm(2.5)
section.right_margin = Cm(2.5)
style = doc.styles['Normal']
font = style.font
font.name = 'Calibri'
font.size = Pt(11)
font.color.rgb = RGBColor(0x33, 0x33, 0x33)
style.paragraph_format.space_after = Pt(6)
style.paragraph_format.line_spacing = 1.15
# Heading styles
for level, size, color in [(1, 16, 0x0D9488), (2, 13, 0x0F766E), (3, 11, 0x115E59)]:
hs = doc.styles[f'Heading {level}']
hs.font.name = 'Calibri'
hs.font.size = Pt(size)
hs.font.color.rgb = RGBColor((color >> 16) & 0xFF, (color >> 8) & 0xFF, color & 0xFF)
hs.font.bold = True
hs.paragraph_format.space_before = Pt(18 if level == 1 else 14)
hs.paragraph_format.space_after = Pt(8)
def add_bullet(text, bold_prefix=None, level=0):
p = doc.add_paragraph(style='List Bullet')
p.paragraph_format.left_indent = Cm(1.2 + level * 0.8)
p.paragraph_format.space_after = Pt(3)
if bold_prefix:
run = p.add_run(bold_prefix)
run.bold = True
run.font.size = Pt(10)
run2 = p.add_run(text)
run2.font.size = Pt(10)
else:
run = p.add_run(text)
run.font.size = Pt(10)
return p
def add_para(text, bold=False, italic=False, size=11, align=None, space_after=6):
p = doc.add_paragraph()
run = p.add_run(text)
run.font.size = Pt(size)
run.bold = bold
run.italic = italic
if align:
p.alignment = align
p.paragraph_format.space_after = Pt(space_after)
return p
def add_table_row(table, cells_data, bold=False, bg_color=None):
row = table.add_row()
for i, text in enumerate(cells_data):
cell = row.cells[i]
cell.text = ''
p = cell.paragraphs[0]
run = p.add_run(str(text))
run.font.size = Pt(9)
run.bold = bold
if bg_color:
shading = OxmlElement('w:shd')
shading.set(qn('w:fill'), bg_color)
shading.set(qn('w:val'), 'clear')
cell._tc.get_or_add_tcPr().append(shading)
return row
def set_table_header_bg(table, color='0D9488'):
for cell in table.rows[0].cells:
shading = OxmlElement('w:shd')
shading.set(qn('w:fill'), color)
shading.set(qn('w:val'), 'clear')
cell._tc.get_or_add_tcPr().append(shading)
for p in cell.paragraphs:
for run in p.runs:
run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
run.bold = True
# ═══════════════════════════════════════════════════════
# COVER PAGE
# ═══════════════════════════════════════════════════════
for _ in range(6):
doc.add_paragraph()
add_para('PROPUNERE TEHNICA', bold=True, size=24, align=WD_ALIGN_PARAGRAPH.CENTER, space_after=4)
add_para('RAPORT INTERMEDIAR DE IMPLEMENTARE', bold=True, size=14, align=WD_ALIGN_PARAGRAPH.CENTER, space_after=20)
# Separator line
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = p.add_run('_' * 60)
run.font.color.rgb = RGBColor(0x0D, 0x94, 0x88)
p.paragraph_format.space_after = Pt(20)
add_para('Dezvoltare website cu e-commerce,\ncu ERP si CRM in cloud', bold=True, size=13, align=WD_ALIGN_PARAGRAPH.CENTER, space_after=8)
add_para('in cadrul proiectului', italic=True, size=10, align=WD_ALIGN_PARAGRAPH.CENTER, space_after=4)
add_para('PLATFORMA DIGITALA INTELIGENTA PENTRU\nPREVENIREA SI COMBATEREA DEZINFORMARII', bold=True, size=12, align=WD_ALIGN_PARAGRAPH.CENTER, space_after=30)
add_para(f'Data document: {datetime.date.today().strftime("%d.%m.%Y")}', size=10, align=WD_ALIGN_PARAGRAPH.CENTER, space_after=4)
add_para('Stadiu: Implementare in curs', italic=True, size=10, align=WD_ALIGN_PARAGRAPH.CENTER)
doc.add_page_break()
# ═══════════════════════════════════════════════════════
# CUPRINS
# ═══════════════════════════════════════════════════════
doc.add_heading('CUPRINS', level=1)
cuprins = [
'1. Scopul documentului',
'2. Stiva tehnologica propusa',
'3. Arhitectura generala si interconectare componente',
'4. Componenta Website — Stadiu implementare',
'5. Componenta ERP/CRM — Stadiu implementare',
'6. Integrari realizate si flux de date',
'7. Securitate si protectia datelor (GDPR)',
'8. Stadiu general si pasi urmatori',
'9. Tabel de conformitate — stadiu curent',
]
for item in cuprins:
add_para(item, size=11, space_after=4)
doc.add_page_break()
# ═══════════════════════════════════════════════════════
# 1. SCOPUL DOCUMENTULUI
# ═══════════════════════════════════════════════════════
doc.add_heading('1. Scopul documentului', level=1)
add_para(
'Prezentul document constituie un raport intermediar de implementare al solutiei tehnice '
'pentru dezvoltarea website-ului cu e-commerce, ERP si CRM in cloud. '
'Documentul prezinta stadiul actual al dezvoltarii, arhitectura implementata, '
'tehnologiile utilizate, functionalitatile livrate pana in acest moment si pasii urmatori.'
)
add_para(
'Scopul este de a oferi Beneficiarului o imagine clara asupra progresului tehnic, '
'a deciziilor arhitecturale luate si a modului in care solutia propusa raspunde '
'cerintelor din Caietul de Sarcini (Capitolul II).'
)
add_para(
'Acest document nu reprezinta livrabilul final — este un punct de referinta intermediar '
'care permite validarea directiei tehnice si colectarea de feedback inainte de finalizare.',
italic=True
)
# ═══════════════════════════════════════════════════════
# 2. STIVA TEHNOLOGICA
# ═══════════════════════════════════════════════════════
doc.add_heading('2. Stiva tehnologica propusa', level=1)
add_para(
'Solutia a fost construita folosind o stiva tehnologica moderna, bazata exclusiv pe '
'tehnologii open-source si frameworkuri cu suport activ pe termen lung. '
'Alegerea fiecarei componente a fost facuta in concordanta cu cerintele din Caietul de Sarcini '
'si cu principiile de modularitate, scalabilitate si securitate.'
)
doc.add_heading('2.1. Componente principale', level=2)
table = doc.add_table(rows=1, cols=4)
table.style = 'Light Grid Accent 1'
table.alignment = WD_TABLE_ALIGNMENT.CENTER
hdr = table.rows[0].cells
hdr[0].text = 'Componenta'
hdr[1].text = 'Tehnologie'
hdr[2].text = 'Versiune'
hdr[3].text = 'Rol in solutie'
set_table_header_bg(table)
tech_rows = [
['Website (Frontend)', 'Next.js (React)', 'v16', 'Website prezentare, e-commerce, dashboard client; Server-Side Rendering pentru SEO'],
['Website (Backend API)', 'Node.js / API Routes', 'v22', 'API-uri server-side pentru checkout, facturare, comunicare cu servicii externe'],
['Limbaj programare', 'TypeScript', 'v5', 'Tipizare statica pe intregul proiect, reducerea erorilor la compilare'],
['Stilizare', 'Tailwind CSS', 'v4', 'Framework CSS utility-first, design responsive, componente consistente'],
['ERP + CRM', 'ERPNext', 'v15', 'Contabilitate, facturare, CRM, rapoarte; solutie open-source GPL, configurata pentru legislatia RO'],
['Baza de date ERP', 'MariaDB', 'v10.11', 'Baza de date relationala pentru ERPNext, optimizata pentru tranzactii'],
['Cache si cozi', 'Redis', 'v7 (Alpine)', 'Cache pentru performanta si coada de taskuri asincrone ERPNext'],
['Autentificare (IAM)', 'Keycloak (SSO)', '-', 'Autentificare unificata prin OpenID Connect, gestionata de platforma existenta'],
['Procesor plati', 'Stripe', 'API v2024', 'Plati securizate cu card, checkout sessions, webhook-uri pentru confirmare'],
['Containerizare', 'Docker + Compose', '-', 'Izolare servicii, deployment reproducibil, orchestrare multi-container'],
['Reverse proxy', 'Nginx', '-', 'Rutare trafic, servire assets statice, proxy catre aplicatie'],
['Internationalizare', 'Custom i18n', '-', 'Suport complet romana (RO) si engleza (EN), comutare dinamica'],
]
for row_data in tech_rows:
add_table_row(table, row_data)
doc.add_heading('2.2. Justificarea alegerii tehnologiilor', level=2)
add_para('Next.js (React)', bold=True, size=10, space_after=2)
add_para(
'Framework-ul Next.js a fost ales pentru capacitatea de Server-Side Rendering (SSR) si '
'Static Site Generation (SSG), esentiale pentru performanta si SEO. App Router-ul permite '
'o structura clara a rutelor, iar React ofera un ecosistem matur de componente reutilizabile. '
'Ref. CS: II.6.1 — "Website dezvoltat cu tehnologii web moderne".',
size=10
)
add_para('ERPNext v15', bold=True, size=10, space_after=2)
add_para(
'ERPNext este o solutie ERP open-source (licenta GPL) cu module native de contabilitate, '
'facturare si CRM. Versiunea 15 ofera API REST complet, suport pentru custom DocTypes '
'si un sistem de permisiuni granulare. Configurarea pentru legislatia romaneasca (plan de conturi, '
'TVA 19%, serie facturi) este realizata prin scripturi de setup automate. '
'Ref. CS: II.6.2 — "Solutia ERP/CRM gazduita in cloud, configurabila".',
size=10
)
add_para('Stripe', bold=True, size=10, space_after=2)
add_para(
'Stripe a fost ales ca procesor de plati pentru suportul nativ de plati recurente (subscriptii), '
'webhook-uri pentru confirmare in timp real, compatibilitate cu piata din Romania si conformitate PCI DSS. '
'Ref. CS: II.6.3 — "Procesor de plati cu suport plati recurente, compatibil Romania".',
size=10
)
add_para('Docker', bold=True, size=10, space_after=2)
add_para(
'Containerizarea cu Docker asigura un mediu de deployment reproductibil si izolat. '
'Fiecare componenta (ERPNext, MariaDB, Redis, workers) ruleaza in container propriu, '
'facilitand scalarea independenta si deployment-ul in cloud EU. '
'Ref. CS: II.3 — "Cloud hosting EU", II.6.2 — "Backup automat".',
size=10
)
doc.add_page_break()
# ═══════════════════════════════════════════════════════
# 3. ARHITECTURA
# ═══════════════════════════════════════════════════════
doc.add_heading('3. Arhitectura generala si interconectare componente', level=1)
add_para(
'Arhitectura solutiei este modulara, cu separare clara intre componenta de prezentare (website), '
'componenta de business logic (ERP/CRM) si serviciile externe (autentificare, plati, email). '
'Comunicarea intre componente se realizeaza exclusiv prin API-uri REST securizate.'
)
doc.add_heading('3.1. Diagrama de interconectare', level=2)
add_para(
'Schema de mai jos ilustreaza fluxul de date intre componentele principale:',
size=10, space_after=10
)
# ASCII-style architecture diagram
arch_text = """
UTILIZATOR (Browser)
Desktop / Tableta / Mobil
HTTPS
WEBSITE (Next.js / React)
Pagini Dashboard API Routes
Publice Client (Server-side)
(SSR/SSG) (Protejat)
API REST API REST + Webhooks
ERP/CRM (ERPNext) SERVICII EXTERNE
Contabilitate IAM (SSO/OpenID)
Facturare Autentificare unificata
CRM / Pipeline
CMS (Continut)
DocTypes Custom Procesor Plati
Checkout + Webhooks
MariaDB + Redis
(Persistenta) API Platforma DiDi
Analize + Credite
"""
p = doc.add_paragraph()
run = p.add_run(arch_text)
run.font.name = 'Consolas'
run.font.size = Pt(7.5)
p.paragraph_format.space_after = Pt(12)
doc.add_heading('3.2. Principii arhitecturale implementate', level=2)
add_bullet('Comunicarea intre website si ERP/CRM se realizeaza prin proxy server-side — '
'credentialele API nu sunt niciodata expuse in browser', bold_prefix='Securitate API: ')
add_bullet('Website-ul (frontend + API) si ERPNext (ERP/CRM) ruleaza in containere Docker separate, '
'cu retea interna partajata; pot fi deployate pe servere diferite', bold_prefix='Separare componente: ')
add_bullet('Continutul paginilor publice (homepage, servicii, despre, privacy, termeni) este gestionat '
'din interfata ERPNext si preluat dinamic de website prin API', bold_prefix='CMS centralizat: ')
add_bullet('Toate paginile publice beneficiaza de Server-Side Rendering pentru indexare optima '
'de catre motoarele de cautare', bold_prefix='SEO prin SSR: ')
add_bullet('Suport complet pentru romana si engleza, cu posibilitate de extindere; '
'comutare dinamica din interfata', bold_prefix='Internationalizare: ')
add_bullet('Utilizator unic autentificat prin SSO — o singura sesiune valida '
'pentru website, dashboard si platforma', bold_prefix='Autentificare unificata: ')
doc.add_heading('3.3. Fluxuri de date principale', level=2)
add_para('Flux 1 — Autentificare utilizator', bold=True, size=10, space_after=2)
add_para(
'Utilizatorul acceseaza pagina de login → este redirectionat catre serverul IAM (OpenID Connect) '
'→ se autentifica → primeste un token JWT → este redirectionat inapoi in website cu sesiune activa '
'→ middleware-ul protejeaza rutele din dashboard pe baza sesiunii.',
size=10
)
add_para('Flux 2 — Achizitie serviciu (one-time)', bold=True, size=10, space_after=2)
add_para(
'Utilizatorul selecteaza un serviciu din pagina de pricing → este redirectionat catre checkout-ul '
'procesorului de plati → efectueaza plata → procesorul trimite un webhook la API-ul website-ului '
'→ API-ul creeaza automat: clientul in ERP (daca nu exista), factura fiscala (Sales Invoice), '
'log-ul tranzactiei → factura este disponibila in dashboard pentru vizualizare si download PDF.',
size=10
)
add_para('Flux 3 — Analiza continut (DiDi API)', bold=True, size=10, space_after=2)
add_para(
'Utilizatorul acceseaza sectiunea de analize din dashboard → selecteaza un serviciu achizitionat '
'si neconsmat → introduce textul sau URL-ul de analizat → API-ul website-ului trimite cererea catre '
'API-ul platformei DiDi → rezultatele sunt preluate prin polling → sunt afisate detaliat '
'(scor de risc, tehnici detectate, verificare afirmatii, detectie AI, credibilitate sursa).',
size=10
)
add_para('Flux 4 — Lead din formular contact', bold=True, size=10, space_after=2)
add_para(
'Vizitatorul completeaza formularul de contact de pe website → datele sunt trimise catre API-ul '
'website-ului → API-ul creeaza automat un Lead in modulul CRM al ERPNext cu sursa, '
'pagina de origine si parametri UTM → Lead-ul intra in pipeline-ul de vanzari.',
size=10
)
add_para('Flux 5 — Gestiune continut CMS', bold=True, size=10, space_after=2)
add_para(
'Administratorul editeaza continutul paginilor (texte, imagini) din interfata ERPNext '
'→ website-ul preia automat continutul actualizat prin API REST '
'→ paginile publice se actualizeaza fara interventie tehnica (fara deploy).',
size=10
)
doc.add_page_break()
# ═══════════════════════════════════════════════════════
# 4. WEBSITE
# ═══════════════════════════════════════════════════════
doc.add_heading('4. Componenta Website — Stadiu implementare', level=1)
add_para(
'Website-ul a fost dezvoltat integral cu Next.js (React) si TypeScript, folosind App Router '
'pentru structurarea rutelor si Tailwind CSS pentru stilizare. Toate paginile sunt responsive '
'si au suport bilingv (RO/EN).'
)
doc.add_heading('4.1. Pagini publice implementate', level=2)
add_para('Ref. CS: II.5.1 — Website de prezentare', italic=True, size=9, space_after=4)
table = doc.add_table(rows=1, cols=3)
table.style = 'Light Grid Accent 1'
hdr = table.rows[0].cells
hdr[0].text = 'Pagina'
hdr[1].text = 'Functionalitate'
hdr[2].text = 'Sursa continut'
set_table_header_bg(table)
pages_data = [
['Homepage', 'Sectiune hero, prezentare servicii (carduri), cum functioneaza (pasi), statistici platforma, cazuri de utilizare, apel la actiune', 'CMS din ERP (dinamic)'],
['Servicii', '6 module AI detaliate cu capabilitati tehnice: analiza text, deepfake, fact-checking, evaluare surse, tehnici manipulare, monitorizare media', 'CMS din ERP (dinamic)'],
['Despre', 'Prezentare companie, misiune, proiect, tehnologie utilizata', 'CMS din ERP (dinamic)'],
['Pricing', 'Grila de preturi pentru 4 tipuri de servicii x 4 tipuri media (text, imagine, audio, video); buton achizitie integrat cu procesorul de plati', 'Configuratie locala + ERP'],
['Contact', 'Formular de contact (nume, email, mesaj) cu trimitere automata in CRM; informatii de contact companie', 'CMS din ERP + formular'],
['Politica confidentialitate', 'Pagina GDPR completa cu descrierea datelor colectate, scopuri, drepturi utilizator, stocare', 'CMS din ERP (dinamic)'],
['Termeni si conditii', 'Definitii, servicii, plati, proprietate intelectuala, raspundere, legislatie aplicabila', 'CMS din ERP (dinamic)'],
]
for row_data in pages_data:
add_table_row(table, row_data)
doc.add_heading('4.2. Zona client autentificata (Dashboard)', level=2)
add_para('Ref. CS: II.4 — Zona client dashboard, II.5.2 — Gestiune abonamente', italic=True, size=9, space_after=4)
table = doc.add_table(rows=1, cols=3)
table.style = 'Light Grid Accent 1'
hdr = table.rows[0].cells
hdr[0].text = 'Pagina'
hdr[1].text = 'Functionalitate'
hdr[2].text = 'Sursa date'
set_table_header_bg(table)
dash_data = [
['Overview', 'Sumar: numar facturi, analize achizitionate, ultimele facturi emise, acces rapid la achizitie', 'ERP (API REST)'],
['Facturi', 'Lista completa facturi cu numar, data, suma, status; download PDF pentru fiecare factura', 'ERP (Sales Invoice)'],
['Analize', 'Lista servicii achizitionate si neconsumate; selectare si rulare analiza; afisare rezultate detaliate (scor risc, tehnici, verificari, detectie AI)', 'ERP + API Platforma'],
['Profil', 'Vizualizare si editare date personale (companie, cod fiscal); sectiune GDPR cu export date si solicitare stergere', 'ERP (Customer)'],
['Abonament', 'Detalii plan activ, lista functionalitati per nivel, data activare', 'ERP (Customer)'],
['Achizitioneaza', 'Grila servicii disponibile cu preturi; flux de achizitie integrat cu procesorul de plati', 'Configuratie + Stripe'],
['Checkout', 'Flux in 2 pasi: selectare plan + date facturare cu acceptare termeni; creare acord de servicii', 'ERP + Stripe'],
['Credite', 'Afisare credite disponibile si consum (pregatit pentru integrare cu API platforma)', 'API Platforma (partial)'],
]
for row_data in dash_data:
add_table_row(table, row_data)
doc.add_heading('4.3. API Routes implementate (Server-side)', level=2)
table = doc.add_table(rows=1, cols=3)
table.style = 'Light Grid Accent 1'
hdr = table.rows[0].cells
hdr[0].text = 'Endpoint'
hdr[1].text = 'Metoda'
hdr[2].text = 'Functionalitate'
set_table_header_bg(table)
api_data = [
['Autentificare', 'GET/POST', 'Handler OpenID Connect — login, callback, refresh token, logout'],
['Proxy ERP', 'GET/POST/PUT', 'Proxy server-side catre API-ul ERP; credentialele nu ajung in browser'],
['Lead-uri', 'POST', 'Creare Lead in CRM din formularul de contact website'],
['Checkout', 'POST', 'Creare sesiune de plata in procesorul de plati; redirect catre pagina de plata'],
['Webhook plati', 'POST', 'Procesare confirmare plata; creare automata client, factura si log tranzactie in ERP'],
['PDF Factura', 'GET', 'Download factura in format PDF din ERP, livrata prin proxy securizat'],
['Analize', 'GET/POST', 'Listare analize disponibile, trimitere cerere analiza, preluare rezultate'],
]
for row_data in api_data:
add_table_row(table, row_data)
doc.add_heading('4.4. Functionalitati transversale', level=2)
add_bullet('Suport complet romana (RO) si engleza (EN) cu comutare din navbar; '
'dictionare JSON pentru toate textele interfetei', bold_prefix='Internationalizare (i18n): ')
add_bullet('Banner cookie consent cu 3 categorii (necesare, functionale, analitice); '
'preferintele se salveaza in browser si se respecta la navigare', bold_prefix='Cookie consent GDPR: ')
add_bullet('Generare automata sitemap.xml si robots.txt; meta tags SEO pe toate paginile; '
'Open Graph tags pentru partajare pe retele sociale', bold_prefix='SEO: ')
add_bullet('Navbar responsive cu meniu hamburger pe mobil; footer cu linkuri utile; '
'sidebar dashboard pe desktop cu navigare vizuala', bold_prefix='Componente layout: ')
add_bullet('Rutele din zona client sunt protejate prin middleware — '
'utilizatorii neautentificati sunt redirectionati automat la pagina de login', bold_prefix='Protectie rute: ')
doc.add_page_break()
# ═══════════════════════════════════════════════════════
# 5. ERP/CRM
# ═══════════════════════════════════════════════════════
doc.add_heading('5. Componenta ERP/CRM — Stadiu implementare', level=1)
add_para(
'Componenta ERP/CRM este construita pe ERPNext v15 (open-source, licenta GPL), '
'containerizata cu Docker si configurata specific pentru legislatia romaneasca si '
'nevoile proiectului. Intreaga configurare este automatizata prin scripturi Python '
'reproductibile.'
)
doc.add_heading('5.1. Infrastructura Docker', level=2)
add_para('Ref. CS: II.6.2 — ERP/CRM gazduita in cloud', italic=True, size=9, space_after=4)
add_para('Mediul ERP/CRM este compus din 7 containere Docker orchestrate prin Docker Compose:')
table = doc.add_table(rows=1, cols=3)
table.style = 'Light Grid Accent 1'
hdr = table.rows[0].cells
hdr[0].text = 'Serviciu'
hdr[1].text = 'Tehnologie'
hdr[2].text = 'Rol'
set_table_header_bg(table)
docker_data = [
['Aplicatie ERP', 'ERPNext v15 (Frappe)', 'Aplicatia principala ERP/CRM; interfata web de administrare; API REST'],
['Baza de date', 'MariaDB 10.11', 'Stocare persistenta; configurata cu InnoDB, UTF8MB4, buffer pool optimizat'],
['Cache', 'Redis (Alpine)', 'Cache pentru performanta — reduce timpul de raspuns al interfetei ERP'],
['Coada de taskuri', 'Redis (Alpine)', 'Coada pentru procesare asincrona (emailuri, taskuri background)'],
['Worker scurt', 'Frappe Worker', 'Procesare taskuri rapide (notificari, actualizari cache)'],
['Worker lung', 'Frappe Worker', 'Procesare taskuri de durata (rapoarte, import-uri, backup)'],
['Scheduler', 'Frappe Scheduler', 'Executie taskuri programate (facturare recurenta, cleanup)'],
]
for row_data in docker_data:
add_table_row(table, row_data)
add_para(
'Toate containerele comunica pe o retea Docker interna izolata. '
'Reverse proxy-ul Nginx gestioneaza traficul HTTP, serveste asseturile statice '
'si ofera suport WebSocket pentru interfata in timp real.',
size=10
)
doc.add_heading('5.2. Configurare contabilitate si facturare', level=2)
add_para('Ref. CS: II.5.3, II.5.4 — Facturare automata, Componenta ERP', italic=True, size=9, space_after=4)
add_bullet('Plan de conturi adaptat legislatiei romanesti (OMFP 1802/2014)', bold_prefix='Plan conturi: ')
add_bullet('TVA 19% Romania — aplicat automat pe net total la emitere factura', bold_prefix='Taxe: ')
add_bullet('Format personalizat cu serie anuala secventiala (format: PREFIX-YYYY-#####)', bold_prefix='Serie facturi: ')
add_bullet('Template PDF personalizat cu antet, date companie, detaliere articole, '
'subtotal + TVA + total, nota legala conform art. 106 alin. 2 din Legea 227/2015',
bold_prefix='Print Format custom: ')
add_bullet('Conturi de cheltuieli configurate: hosting, servicii software, marketing, comisioane plati',
bold_prefix='Conturi cheltuieli: ')
add_bullet('Evidenta furnizori principali (hosting EU, procesor plati, email tranzactional)',
bold_prefix='Furnizori: ')
doc.add_heading('5.3. Configurare CRM', level=2)
add_para('Ref. CS: II.5.5 — Componenta CRM', italic=True, size=9, space_after=4)
add_bullet('4 etape configurate: Lead → Calificat → Demo → Client', bold_prefix='Pipeline vanzari: ')
add_bullet('Surse configurate: formular contact website, pagina pricing, cerere demo',
bold_prefix='Surse lead-uri: ')
add_bullet('Campuri pentru sursa formular, pagina de origine si parametri UTM '
'(sursa, mediu, campanie) — permit analiza eficientei canalelor de marketing',
bold_prefix='Campuri custom pe Lead: ')
add_bullet('Campuri pentru ID utilizator in platforma IAM, rol activ (free/paid/enterprise), '
'plan activ si data activarii',
bold_prefix='Campuri custom pe Client: ')
doc.add_heading('5.4. DocTypes custom create', level=2)
add_para('Ref. CS: II.5.6 — Gestiune contractuala, II.8 — Audit', italic=True, size=9, space_after=4)
add_para(
'Pe langa configurarea modulelor standard ERPNext, au fost create 3 DocTypes (entitati de date) '
'custom, specifice nevoilor proiectului:'
)
add_para('Acord de Servicii (Service Agreement)', bold=True, size=10, space_after=2)
add_para(
'Se genereaza automat la activarea unui serviciu platit. Contine: clientul, planul selectat, '
'status-ul acordului (Draft / Acceptat / Expirat / Anulat), data si ora acceptarii, '
'versiunea termenilor, continutul HTML al acordului si legatura catre abonament. '
'Numerotare automata secventiala. Asigura trasabilitate completa (audit trail) conform cerintei II.5.6.',
size=10
)
add_para('Log Plati (Payment Log)', bold=True, size=10, space_after=2)
add_para(
'Inregistreaza fiecare eveniment de plata receptionat prin webhook de la procesorul de plati. '
'Contine: clientul, tipul evenimentului (plata reusita, esec, rambursare, modificare abonament), '
'status-ul, suma, moneda si datele brute ale webhook-ului in format JSON. '
'Permite audit complet al tranzactiilor financiare conform cerintei II.8.',
size=10
)
add_para('Continut Website / CMS (Website Content)', bold=True, size=10, space_after=2)
add_para(
'Permite administratorului sa editeze continutul tuturor paginilor publice direct din interfata ERP, '
'fara interventie tehnica. Fiecare intrare are: pagina (homepage, pricing, contact, despre, privacy, termeni), '
'cheie sectiune, ordine afisare, status activ/inactiv, continut in limba romana, continut in limba engleza, '
'imagine si date structurate suplimentare in format JSON. '
'In prezent sunt configurate 26 de intrari CMS corespunzand tuturor sectiunilor de pe paginile publice.',
size=10
)
doc.add_heading('5.5. Catalog servicii', level=2)
add_para(
'In ERP au fost create 14 articole (Item) corespunzand serviciilor platformei, '
'organizate pe 4 categorii de servicii x tipuri de media suportate:'
)
table = doc.add_table(rows=1, cols=2)
table.style = 'Light Grid Accent 1'
hdr = table.rows[0].cells
hdr[0].text = 'Categorie serviciu'
hdr[1].text = 'Tipuri media'
set_table_header_bg(table)
items_data = [
['Detectie tehnici de manipulare', 'Text, Imagine, Audio, Video'],
['Detectie AI si Deepfake', 'Text, Imagine, Audio, Video'],
['Verificare automata afirmatii (Fact-checking)', 'Text, Imagine, Audio, Video'],
['Evaluare sursa si domeniu', 'Text, URL'],
]
for row_data in items_data:
add_table_row(table, row_data)
doc.add_heading('5.6. Utilizator API dedicat', level=2)
add_para(
'Pentru comunicarea securizata intre website si ERP, a fost creat un utilizator API dedicat '
'cu rol custom ("Website Integration") si permisiuni limitate strict la operatiile necesare:',
size=10
)
add_bullet('Citire si scriere: Client, Lead, Factura, Abonament, Continut Website, Acord Servicii, Log Plati, Articol',
bold_prefix='Permisiuni read/write: ')
add_bullet('Doar citire: Companie, Conturi, Taxe, Template-uri email, Etape vanzari',
bold_prefix='Permisiuni read-only: ')
add_para(
'Autentificarea se face prin API Key + Secret, transmise exclusiv server-side (nu ajung in browser).',
size=10, italic=True
)
doc.add_page_break()
# ═══════════════════════════════════════════════════════
# 6. INTEGRARI
# ═══════════════════════════════════════════════════════
doc.add_heading('6. Integrari realizate si flux de date', level=1)
add_para('Ref. CS: II.6.3 — Integrari', italic=True, size=9, space_after=4)
doc.add_heading('6.1. Autentificare IAM (SSO / OpenID Connect)', level=2)
add_para(
'Website-ul este integrat cu componenta IAM a platformei existente prin protocolul OpenID Connect. '
'Fluxul de autentificare:',
size=10
)
add_bullet('Utilizatorul apasa butonul de login pe website')
add_bullet('Este redirectionat catre serverul IAM (pagina de autentificare centralizata)')
add_bullet('Dupa autentificare, primeste un token JWT (cu roluri din realm)')
add_bullet('Este redirectionat inapoi pe website cu sesiune activa')
add_bullet('Token-ul se reinnoieste automat (refresh token) fara interventie')
add_bullet('Middleware-ul Next.js protejeaza toate rutele din zona client')
add_para(
'Rolurile din IAM (free_tier, paid_tier, enterprise_tier) sunt mapate in sesiunea website '
'si utilizate pentru controlul accesului bazat pe roluri (RBAC).',
size=10
)
doc.add_heading('6.2. Procesor plati (Stripe)', level=2)
add_para(
'Integrarea cu procesorul de plati este realizata prin doua mecanisme complementare:',
size=10
)
add_para('Checkout Sessions', bold=True, size=10, space_after=2)
add_para(
'La initierea unei achizitii, API-ul website-ului creeaza o sesiune de checkout securizata '
'pe serverele procesorului de plati. Utilizatorul este redirectionat catre pagina de plata '
'(gazduita de procesorul de plati — conformitate PCI DSS) si, dupa plata reusita, '
'revine pe website pe pagina de confirmare.',
size=10
)
add_para('Webhook-uri', bold=True, size=10, space_after=2)
add_para(
'La confirmarea platii, procesorul de plati trimite un webhook (notificare HTTP) catre API-ul website-ului. '
'Webhook-ul declanseaza automat:',
size=10
)
add_bullet('Crearea sau identificarea clientului in ERP (pe baza ID-ului din IAM)')
add_bullet('Crearea facturii fiscale (Sales Invoice) cu articolele achizitionate si TVA')
add_bullet('Inregistrarea tranzactiei in Log Plati (Payment Log) pentru audit')
add_bullet('Crearea articolului in catalog daca nu exista deja')
add_para(
'Au fost configurate 14 produse in procesorul de plati, corespunzand celor 14 articole din ERP, '
'cu preturi in RON.',
size=10
)
doc.add_heading('6.3. Website ↔ ERP/CRM (API REST)', level=2)
add_para(
'Comunicarea intre website si ERP se realizeaza prin API REST, cu un proxy server-side '
'implementat in Next.js. Aceasta abordare ofera doua avantaje majore:',
size=10
)
add_bullet('Credentialele API nu sunt niciodata expuse in browserul utilizatorului')
add_bullet('Website-ul poate adauga logica intermediara (validare, transformare date, caching)')
add_para('Operatii implementate:', bold=True, size=10, space_after=2)
add_bullet('Citire continut CMS (Homepage, Servicii, Despre, Privacy, Termeni) → cache 60 secunde')
add_bullet('Creare Lead din formular contact (cu sursa, pagina, UTM)')
add_bullet('Citire facturi client din ERP + download PDF')
add_bullet('Citire/actualizare date profil client')
add_bullet('Creare Acord de Servicii la checkout')
add_bullet('Creare factura si client la confirmare plata')
doc.add_heading('6.4. API Platforma DiDi (Analize)', level=2)
add_para(
'Website-ul este integrat cu API-ul platformei DiDi pentru submisia si preluarea analizelor. '
'Integrarea acopera 4 endpoint-uri corespunzand celor 4 categorii de servicii:',
size=10
)
table = doc.add_table(rows=1, cols=2)
table.style = 'Light Grid Accent 1'
hdr = table.rows[0].cells
hdr[0].text = 'Serviciu'
hdr[1].text = 'Endpoint API'
set_table_header_bg(table)
api_didi = [
['Detectie tehnici de manipulare', '/v3/techniques/analyze'],
['Detectie AI si deepfake', '/v3/ai-tampered/analyze'],
['Verificare afirmatii', '/v3/claims/analyze'],
['Evaluare sursa/domeniu', '/v3/domain/analyze'],
]
for row_data in api_didi:
add_table_row(table, row_data)
add_para(
'Autentificarea catre API-ul platformei se face cu Bearer token (obtinut din sesiunea IAM). '
'Rezultatele analizelor includ: scor de risc, nivel de incredere, tehnici de manipulare detectate, '
'verificarea afirmatiilor (adevarat/fals/neverificat), probabilitate de generare AI, '
'scor de credibilitate domeniu si factori de viralitate.',
size=10
)
doc.add_page_break()
# ═══════════════════════════════════════════════════════
# 7. SECURITATE
# ═══════════════════════════════════════════════════════
doc.add_heading('7. Securitate si protectia datelor (GDPR)', level=1)
add_para('Ref. CS: II.8 — Securitate cibernetica si protectia datelor', italic=True, size=9, space_after=4)
doc.add_heading('7.1. Masuri de securitate implementate', level=2)
table = doc.add_table(rows=1, cols=3)
table.style = 'Light Grid Accent 1'
hdr = table.rows[0].cells
hdr[0].text = 'Categorie'
hdr[1].text = 'Masura'
hdr[2].text = 'Implementare'
set_table_header_bg(table)
sec_data = [
['Autentificare', 'SSO / OpenID Connect', 'Autentificare centralizata prin IAM; token JWT cu expirare si refresh automat'],
['Autorizare', 'RBAC (Role-Based Access Control)', 'Roluri mapate din IAM: free_tier, paid_tier, enterprise_tier; middleware pe rute protejate'],
['Transport', 'HTTPS obligatoriu', 'Certificat SSL; comunicatie criptata TLS 1.2+ pe toate endpoint-urile'],
['API Security', 'Proxy server-side', 'Credentialele ERP nu ajung in browser; validare pe server a tuturor cererilor'],
['Plati', 'PCI DSS compliance', 'Datele cardurilor nu tranziteaza serverele noastre — plata se face pe pagina procesorului de plati'],
['Webhook-uri', 'Validare semnatura', 'Fiecare webhook de la procesorul de plati este validat prin semnatura criptografica'],
['Sesiuni', 'JWT + Refresh Token', 'Token-uri cu expirare scurta, reinnoite automat; sesiune invalidata la logout'],
['API ERP', 'Utilizator dedicat cu permisiuni minime', 'Principiul least privilege: acces doar la DocType-urile necesare, fara acces admin'],
]
for row_data in sec_data:
add_table_row(table, row_data)
doc.add_heading('7.2. Conformitate GDPR', level=2)
add_bullet('Pagina completa de politica de confidentialitate, editabila din CMS',
bold_prefix='Politica confidentialitate: ')
add_bullet('Pagina completa de termeni si conditii, editabila din CMS',
bold_prefix='Termeni si conditii: ')
add_bullet('Banner cookie consent cu 3 categorii: necesare (always on), functionale, analitice; '
'preferintele se salveaza si se respecta',
bold_prefix='Cookie consent: ')
add_bullet('Buton "Exporta datele mele" in pagina de profil din dashboard (format CSV)',
bold_prefix='Drept de portabilitate: ')
add_bullet('Buton "Solicita stergerea contului" in pagina de profil din dashboard',
bold_prefix='Drept la stergere: ')
add_bullet('Checkbox obligatoriu de acceptare a termenilor in fluxul de checkout, '
'cu inregistrare data, ora si versiune termeni',
bold_prefix='Consimtamant explicit: ')
add_bullet('Toate datele sunt stocate pe servere localizate in Uniunea Europeana',
bold_prefix='Stocare exclusiv in EU: ')
doc.add_heading('7.3. Audit si trasabilitate', level=2)
add_para(
'Toate actiunile critice sunt inregistrate si trasabile:',
size=10
)
add_bullet('Fiecare tranzactie financiara este logata in DocType-ul Payment Log cu date brute webhook')
add_bullet('Fiecare acord de servicii inregistreaza data acceptarii, versiunea termenilor')
add_bullet('Lead-urile captate inregistreaza sursa, pagina de origine si parametri UTM')
add_bullet('ERPNext ofera nativ audit trail pe toate modificarile de documente (Version Log)')
doc.add_page_break()
# ═══════════════════════════════════════════════════════
# 8. STADIU GENERAL
# ═══════════════════════════════════════════════════════
doc.add_heading('8. Stadiu general si pasi urmatori', level=1)
doc.add_heading('8.1. Ce este implementat si functional', level=2)
add_bullet('Infrastructura Docker completa (7 containere ERP + website)')
add_bullet('Website cu 7 pagini publice, toate responsive si bilingve (RO/EN)')
add_bullet('Dashboard client cu 8 pagini functionale')
add_bullet('7 API routes server-side')
add_bullet('ERP configurat: contabilitate RO, TVA, serie facturi, print format PDF custom')
add_bullet('CRM configurat: pipeline 4 etape, campuri custom pe Lead si Client')
add_bullet('3 DocTypes custom: Acord Servicii, Log Plati, Continut Website (CMS)')
add_bullet('26 intrari CMS populate (toate paginile publice, RO + EN)')
add_bullet('14 articole servicii in catalog ERP')
add_bullet('Integrare IAM/SSO functionala (OpenID Connect)')
add_bullet('Integrare procesor plati functionala (checkout + webhook)')
add_bullet('Integrare API platforma DiDi pentru analize (4 endpoint-uri)')
add_bullet('Proxy API securizat (credentiale server-side)')
add_bullet('Cookie consent, sitemap.xml, robots.txt, SEO metadata')
add_bullet('i18n complet (romana + engleza)')
doc.add_heading('8.2. In curs de finalizare', level=2)
table = doc.add_table(rows=1, cols=3)
table.style = 'Light Grid Accent 1'
hdr = table.rows[0].cells
hdr[0].text = 'Element'
hdr[1].text = 'Stadiu'
hdr[2].text = 'Detalii'
set_table_header_bg(table)
wip_data = [
['Interfata admin ERP (workspace-uri)', 'In lucru', 'Organizare sidebar ERP cu sectiuni clare: Facturi, Clienti, CRM, Plati, CMS'],
['Email tranzactional (SendGrid)', 'De implementat', 'Trimitere automata factura PDF pe email la emitere; notificari expirare/esec'],
['Rapoarte ERP custom', 'De implementat', 'Dashboard financiar (MRR, churn), rapoarte CRM (conversie, timp mediu)'],
['Sincronizare credite/consum DiDi API', 'Partial', 'Endpoint-uri testate si functionale; necesita configurare acces retea'],
['Design si assets vizuale', 'De rafinat', 'Adaugare imagini, ilustratii, animatii pe paginile publice'],
['Testare WCAG 2.1 AA', 'De realizat', 'Audit automat (axe/Lighthouse) + verificare manuala navigare tastatura'],
['Testare securitate OWASP', 'De realizat', 'Scan vulnerabilitati, verificare CSRF/XSS/SQLi, rate limiting'],
['Testare cross-browser', 'De realizat', 'Chrome, Firefox, Safari, Edge — ultimele 2 versiuni; responsive pe 5 rezolutii'],
['Deployment productie', 'De realizat', 'VPS-uri cloud EU, Nginx, SSL, backup automat zilnic'],
['Documentatie si training', 'De realizat', 'Manual utilizare, documentatie API, ghid admin ERP, 2 sesiuni training'],
]
for row_data in wip_data:
add_table_row(table, row_data)
doc.add_page_break()
# ═══════════════════════════════════════════════════════
# 9. TABEL CONFORMITATE
# ═══════════════════════════════════════════════════════
doc.add_heading('9. Tabel de conformitate — stadiu curent', level=1)
add_para(
'Tabelul de mai jos prezinta stadiul de implementare pentru fiecare cerinta din Caietul de Sarcini '
'(Capitolul II.10). Stadiul "Implementat" indica functionalitatile livrate si testate; '
'"Partial" indica functionalitatile in curs; "Planificat" indica elementele programate.',
size=10
)
table = doc.add_table(rows=1, cols=4)
table.style = 'Light Grid Accent 1'
hdr = table.rows[0].cells
hdr[0].text = 'Nr.'
hdr[1].text = 'Cerinta CS'
hdr[2].text = 'Stadiu'
hdr[3].text = 'Observatii'
set_table_header_bg(table)
conf_data = [
['1', 'Website prezentare firma si servicii', 'Implementat', '7 pagini publice, continut dinamic din CMS'],
['2', 'Design responsive (desktop, tableta, mobil)', 'Implementat', 'Tailwind CSS, layout-uri responsive pe toate paginile'],
['3', 'Accesibilitate WCAG 2.1 nivel AA', 'Partial', 'Structura semantica implementata; audit formal planificat'],
['4', 'Modul e-commerce cu plati recurente', 'Implementat', 'Checkout integrat cu procesor plati; servicii one-time functionale'],
['5', 'Gestiune abonamente/servicii', 'Implementat', 'Planuri configurate in ERP; sincronizare cu IAM'],
['6', 'Facturare automata conform legislatiei RO', 'Implementat', 'Facturi automate la plata; serie, TVA 19%, PDF custom'],
['7', 'Componenta ERP — contabilitate si gestiune', 'Implementat', 'Plan conturi RO, conturi cheltuieli, furnizori, jurnale'],
['8', 'Componenta CRM — clienti si pipeline', 'Implementat', 'Pipeline 4 etape, lead capture din website, campuri custom'],
['9', 'Integrare IAM platforma (SSO)', 'Implementat', 'OpenID Connect functional; sesiune, token refresh, RBAC'],
['10', 'Sincronizare abonamente si credite API', 'Partial', 'Endpoint-uri testate; configurare acces retea in curs'],
['11', 'Integrare procesor de plati', 'Implementat', '14 produse configurate; checkout + webhook functional'],
['12', 'Integrare email tranzactional', 'Planificat', 'Template-uri email create in ERP; integrare SendGrid planificata'],
['13', 'Integrare website — ERP/CRM', 'Implementat', 'API REST proxy; facturare, lead-uri, CMS, facturi PDF'],
['14', 'Conformitate GDPR', 'Implementat', 'Cookie consent, privacy policy, export date, stergere cont'],
['15', 'Hosting in EU', 'Planificat', 'Infrastructura Docker pregatita; deployment pe VPS EU planificat'],
['16', 'Disponibilitate minim 99.5% uptime', 'Planificat', 'Se va valida in productie cu monitorizare'],
['17', 'Backup automat zilnic', 'Planificat', 'Procedura definita; implementare la deployment productie'],
['18', 'Documentatie utilizare si tehnica', 'Planificat', 'Manual utilizare, documentatie API, ghid admin'],
['19', 'Training echipa', 'Planificat', '2 sesiuni planificate (website + ERP), cu inregistrare video'],
['20', 'Garantie si suport minim 36 luni', 'Conform oferta', 'SLA definit: Critica 4h/24h, Majora 8h/3zile, Minora 24h/10zile'],
]
for row_data in conf_data:
add_table_row(table, row_data)
# ── Footer note ──
doc.add_paragraph()
p = doc.add_paragraph()
run = p.add_run(
'Acest document a fost generat ca raport intermediar de progres. '
'Versiunea finala a propunerii tehnice va include toate livrabilele complete, '
'rapoartele de testare si documentatia aferenta.'
)
run.font.size = Pt(9)
run.italic = True
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
# ── Save ──
output_path = r'C:\Users\avedev\Desktop\Achizitii TOP\livrabile\didi\Propunere_Tehnica_Raport_Intermediar.docx'
doc.save(output_path)
print(f'Document salvat: {output_path}')

View file

@ -0,0 +1,7 @@
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-md">{children}</div>
</div>
);
}

View file

@ -0,0 +1,44 @@
import { redirect } from "next/navigation";
import { auth, signIn } from "@/lib/auth";
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ callbackUrl?: string }>;
}) {
const session = await auth();
const { callbackUrl } = await searchParams;
const redirectTo = callbackUrl || "/dashboard";
if (session) redirect(redirectTo);
return (
<div className="rounded-2xl border bg-white p-8 shadow-sm">
<h1 className="text-2xl font-bold text-gray-900">Autentificare</h1>
<p className="mt-2 text-sm text-gray-600">Conecteaza-te la contul tau Clossers</p>
<form
className="mt-6"
action={async () => {
"use server";
await signIn("keycloak", { redirectTo });
}}
>
<button
type="submit"
className="w-full rounded-lg bg-teal-600 py-3 text-sm font-semibold text-white hover:bg-teal-700"
>
Conecteaza-te cu Clossers
</button>
</form>
<p className="mt-4 text-center text-xs text-gray-500">
Vei fi redirectionat catre pagina de autentificare securizata.
</p>
<p className="mt-6 text-center text-sm text-gray-500">
Nu ai cont? <a href="/register" className="font-medium text-teal-600 hover:text-teal-700">Inregistreaza-te gratuit</a>
</p>
</div>
);
}

View file

@ -0,0 +1,48 @@
import { redirect } from "next/navigation";
import { auth, signIn } from "@/lib/auth";
export default async function RegisterPage() {
const session = await auth();
if (session) redirect("/dashboard");
// Keycloak handles registration - same flow, user clicks "Register" on Keycloak login page
return (
<div className="rounded-2xl border bg-white p-8 shadow-sm">
<h1 className="text-2xl font-bold text-gray-900">Inregistrare</h1>
<p className="mt-2 text-sm text-gray-600">Creeaza un cont DiDi gratuit</p>
<form
className="mt-6"
action={async () => {
"use server";
await signIn("keycloak", { redirectTo: "/dashboard" });
}}
>
<button
type="submit"
className="w-full rounded-lg bg-teal-600 py-3 text-sm font-semibold text-white hover:bg-teal-700"
>
Creeaza cont Clossers
</button>
</form>
<p className="mt-4 text-center text-xs text-gray-500">
Vei fi redirectionat catre pagina de inregistrare securizata Clossers.
</p>
<div className="mt-6 rounded-lg bg-teal-50 p-4 text-xs text-teal-900">
<p className="font-semibold">Ce primesti gratuit?</p>
<ul className="mt-2 space-y-1 list-disc list-inside">
<li>5 analize text pe luna</li>
<li>Acces dashboard complet</li>
<li>Istoric analize + export PDF</li>
<li>Suport email</li>
</ul>
</div>
<p className="mt-6 text-center text-sm text-gray-500">
Ai deja cont? <a href="/login" className="font-medium text-teal-600 hover:text-teal-700">Conecteaza-te</a>
</p>
</div>
);
}

View file

@ -0,0 +1,115 @@
"use client";
import { useState } from "react";
const oneTimeServices = [
{
component: "techniques",
label: "Detectie Tehnici de Manipulare",
description: "Identificare tehnici de propaganda si dezinformare din continut media",
prices: [
{ type: "text", label: "Text", ron: 50, eur: 10 },
{ type: "image", label: "Imagine", ron: 75, eur: 15 },
{ type: "audio", label: "Audio", ron: 100, eur: 20 },
{ type: "video", label: "Video", ron: 200, eur: 40 },
],
},
{
component: "ai_detection",
label: "AI Detection & Deepfake",
description: "Detectie continut generat sau manipulat de AI, inclusiv deepfake",
prices: [
{ type: "text", label: "Text", ron: 50, eur: 10 },
{ type: "image", label: "Imagine", ron: 75, eur: 15 },
{ type: "audio", label: "Audio", ron: 100, eur: 20 },
{ type: "video", label: "Video", ron: 200, eur: 40 },
],
},
{
component: "claims",
label: "Fact-Checking",
description: "Verificare automata a afirmatiilor cu surse credibile si baze de date",
prices: [
{ type: "text", label: "Text", ron: 50, eur: 10 },
{ type: "image", label: "Imagine", ron: 75, eur: 15 },
{ type: "audio", label: "Audio", ron: 75, eur: 15 },
{ type: "video", label: "Video", ron: 150, eur: 30 },
],
},
{
component: "source",
label: "Evaluare Surse",
description: "Analiza credibilitate domenii web, publicatii si conturi social media",
prices: [
{ type: "text", label: "Text", ron: 50, eur: 10 },
{ type: "url", label: "URL", ron: 50, eur: 10 },
],
},
];
export default function AchizitioneazaPage() {
const [loading, setLoading] = useState<string | null>(null);
async function handleBuy(serviceKey: string) {
setLoading(serviceKey);
try {
const res = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ serviceKey }),
});
const data = await res.json();
if (data.url) {
window.location.href = data.url;
}
} catch {
alert("Eroare la procesarea platii. Incearca din nou.");
} finally {
setLoading(null);
}
}
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">Achizitioneaza analiza</h1>
<p className="mt-1 text-sm text-gray-500">
Plateste per analiza. Fara abonament. Alege serviciul si tipul de continut.
</p>
<div className="mt-6 space-y-6">
{oneTimeServices.map((svc) => (
<div key={svc.component} className="overflow-hidden rounded-xl border">
<div className="border-b bg-gray-50 px-5 py-4">
<h2 className="font-semibold text-gray-900">{svc.label}</h2>
<p className="mt-0.5 text-sm text-gray-500">{svc.description}</p>
</div>
<div className="grid grid-cols-2 gap-px bg-gray-100 sm:grid-cols-4">
{svc.prices.map((p) => {
const key = `${svc.component}-${p.type}`;
const isLoading = loading === key;
return (
<div key={p.type} className="flex flex-col items-center bg-white p-4">
<p className="text-xs font-medium uppercase tracking-wider text-gray-500">{p.label}</p>
<p className="mt-2 text-xl font-bold text-gray-900">{p.ron}</p>
<p className="text-xs text-gray-500">RON</p>
<button
onClick={() => handleBuy(key)}
disabled={isLoading}
className="mt-3 w-full rounded-lg bg-teal-600 py-2 text-xs font-semibold text-white transition hover:bg-teal-700 disabled:opacity-50"
>
{isLoading ? "Se proceseaza..." : "Cumpara"}
</button>
</div>
);
})}
{svc.prices.length < 4 &&
Array.from({ length: 4 - svc.prices.length }).map((_, i) => (
<div key={`empty-${i}`} className="bg-gray-50 p-4" />
))}
</div>
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function AnalyzaRedirect() {
redirect("/dashboard/analize");
}

View file

@ -0,0 +1,664 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import {
buildAnalysisReportFileName,
buildAnalysisReportPdf,
} from "@/lib/analysis-report-pdf";
import { formatAnalysisLabel } from "@/lib/analysis-display";
import { useLocale } from "@/i18n/useLocale";
interface AvailableAnalysis {
invoice: string;
item_code: string;
item_name: string;
component: string;
media: string;
posting_date: string;
amount: number;
currency: string;
// present only for subscription (credit-based) entries
plan?: string;
credits_available?: number;
}
interface SavedAnalysisReport {
name: string;
sales_invoice: string;
component: string;
media_type: string;
status: string;
pdf_file?: string | null;
generated_at?: string | null;
creation?: string;
}
type Phase = "list" | "input" | "running" | "results";
const SERVICE_LABELS: Record<string, string> = {
TECHNIQUES: "Detectie Tehnici de Manipulare",
AI_DETECTION: "AI Detection & Deepfake",
CLAIMS: "Verificare Afirmatii (Fact-Checking)",
SOURCE: "Evaluare Surse & Domeniu",
};
const MEDIA_LABELS: Record<string, string> = {
TEXT: "Text",
IMAGE: "Imagine",
AUDIO: "Audio",
VIDEO: "Video",
URL: "URL",
};
const COMPONENT_ICONS: Record<string, string> = {
TECHNIQUES: "🔍",
AI_DETECTION: "🤖",
CLAIMS: "✅",
SOURCE: "🌐",
};
const FILE_MEDIA_TYPES = new Set(["IMAGE", "AUDIO", "VIDEO"]);
const ACCEPT_MAP: Record<string, string> = {
IMAGE: "image/*,.png,.jpg,.jpeg,.webp,.gif",
AUDIO: "audio/*,.mp3,.wav,.ogg,.m4a,.mp4",
VIDEO: "video/*,.mp4,.webm,.mov,.avi",
};
function hasFinalResult(data: unknown) {
if (!data || typeof data !== "object") return false;
const result = data as Record<string, unknown>;
return (
result.status === "completed" ||
typeof result.risk_score === "number" ||
!!result.result ||
!!result.verdict ||
!!result.techniques ||
!!result.claims ||
!!result.ai_tampered ||
!!result.domain ||
!!result.source_assessment
);
}
export default function AnalizePage() {
const { locale } = useLocale();
const [phase, setPhase] = useState<Phase>("list");
const [available, setAvailable] = useState<AvailableAnalysis[]>([]);
const [reports, setReports] = useState<SavedAnalysisReport[]>([]);
const [selected, setSelected] = useState<AvailableAnalysis | null>(null);
const [inputText, setInputText] = useState("");
const [inputUrl, setInputUrl] = useState("");
const [inputFile, setInputFile] = useState<File | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [pollCount, setPollCount] = useState(0);
const [result, setResult] = useState<Record<string, unknown> | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchAvailable = useCallback(async () => {
setLoading(true);
setError("");
try {
const res = await fetch("/api/analyze");
const data = await res.json();
setAvailable(data.data || []);
} catch {
setError("Nu s-au putut incarca analizele disponibile.");
} finally {
setLoading(false);
}
}, []);
const fetchReports = useCallback(async () => {
try {
const res = await fetch("/api/analysis-reports");
const data = await res.json();
setReports(data.data || []);
} catch {
setReports([]);
}
}, []);
const persistReport = useCallback(
async (analysis: AvailableAnalysis, payload: Record<string, unknown>) => {
const sessionId = String(payload.session_id || "");
if (!sessionId) return;
try {
await fetch("/api/analysis-reports", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
invoiceName: analysis.invoice,
sessionId,
component: analysis.component,
media: analysis.media,
locale,
status: "Completed",
result: payload,
}),
});
} finally {
fetchReports();
}
},
[fetchReports],
);
useEffect(() => {
fetchAvailable();
fetchReports();
}, [fetchAvailable, fetchReports]);
useEffect(() => {
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, []);
function handleSelect(analysis: AvailableAnalysis) {
setSelected(analysis);
setInputText("");
setInputUrl("");
setInputFile(null);
setError("");
setPhase("input");
}
function handleBack() {
if (pollRef.current) clearInterval(pollRef.current);
setPhase("list");
setSelected(null);
setResult(null);
setError("");
fetchAvailable();
fetchReports();
}
function downloadPDF() {
if (!result || !selected) return;
const pdf = buildAnalysisReportPdf(result, {
invoice: selected.invoice,
component: selected.component,
media: selected.media,
locale,
});
const blob = new Blob([pdf], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = buildAnalysisReportFileName({
invoice: selected.invoice,
component: selected.component,
media: selected.media,
});
link.click();
URL.revokeObjectURL(url);
}
async function handleSubmit() {
if (!selected) return;
const needsUrl = selected.media === "URL";
const needsFile = FILE_MEDIA_TYPES.has(selected.media);
if (needsFile && !inputFile) {
setError("Selecteaza un fisier de analizat.");
return;
}
if (!needsFile && !needsUrl && !inputText.trim()) {
setError("Introdu textul de analizat.");
return;
}
if (needsUrl && !inputUrl.trim()) {
setError("Introdu URL-ul de analizat.");
return;
}
setError("");
setPhase("running");
setPollCount(0);
try {
let res: Response;
if (needsFile && inputFile) {
const formData = new FormData();
formData.append("invoice", selected.invoice);
formData.append("component", selected.component);
formData.append("media", selected.media);
formData.append("language", locale);
formData.append("file", inputFile);
res = await fetch("/api/analyze", {
method: "POST",
body: formData,
});
} else {
const payload: Record<string, string> = {
invoice: selected.invoice,
component: selected.component,
media: selected.media,
language: locale,
};
if (needsUrl) payload.url = inputUrl.trim();
else payload.text = inputText.trim();
res = await fetch("/api/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
const data = await res.json();
if (!res.ok) {
setError(data.error || `Eroare API: ${res.status}`);
setPhase("input");
return;
}
if (hasFinalResult(data.data)) {
setResult(data.data);
setPhase("results");
await persistReport(selected, data.data as Record<string, unknown>);
return;
}
const sessionId = data.data?.session_id || data.session_id;
if (sessionId) {
startPolling(sessionId, selected);
} else {
setResult(data.data || data);
setPhase("results");
}
} catch {
setError("Eroare la trimiterea analizei.");
setPhase("input");
}
}
function startPolling(sessionId: string, analysis: AvailableAnalysis) {
if (pollRef.current) clearInterval(pollRef.current);
let count = 0;
pollRef.current = setInterval(async () => {
count++;
setPollCount(count);
if (count > 200) {
if (pollRef.current) clearInterval(pollRef.current);
setError("Timeout - analiza dureaza prea mult.");
setPhase("input");
return;
}
try {
const params = new URLSearchParams({
session_id: sessionId,
invoice: analysis.invoice,
component: analysis.component,
media: analysis.media,
language: locale,
});
const res = await fetch(`/api/analyze?${params.toString()}`);
const data = await res.json();
const current = data?.data || data;
if (hasFinalResult(current)) {
if (pollRef.current) clearInterval(pollRef.current);
setResult(current);
setPhase("results");
await persistReport(analysis, current as Record<string, unknown>);
return;
}
if (current?.status === "failed") {
if (pollRef.current) clearInterval(pollRef.current);
setError("Analiza a esuat pe server.");
setPhase("input");
}
} catch {
// keep polling
}
}, 3000);
}
if (phase === "list") {
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">Analize</h1>
<p className="mt-1 text-sm text-gray-500">Selecteaza o analiza achizitionata pentru a o rula.</p>
{error && <div role="alert" className="mt-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">{error}</div>}
{loading ? (
<div className="mt-8 flex justify-center" role="status" aria-live="polite">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-teal-200 border-t-teal-600" aria-hidden="true" />
<span className="sr-only">Se incarca...</span>
</div>
) : (
<div className="mt-6 space-y-6">
{available.length === 0 ? (
<div className="rounded-xl border border-dashed border-gray-300 p-8 text-center">
<p className="text-gray-500">Nicio analiza disponibila.</p>
<p className="mt-1 text-xs text-gray-500">Analizele achizitionate si neconsumate vor aparea aici.</p>
<Link href="/dashboard/achizitioneaza" className="mt-4 inline-block rounded-lg bg-teal-600 px-4 py-2 text-sm font-semibold text-white hover:bg-teal-700">
Achizitioneaza o analiza
</Link>
</div>
) : (
<div className="space-y-3">
{available.map((analysis) => (
<button
key={`${analysis.invoice}-${analysis.item_code}`}
onClick={() => handleSelect(analysis)}
className="flex w-full items-center justify-between rounded-xl border border-gray-200 p-4 text-left transition hover:border-teal-300 hover:bg-teal-50"
>
<div className="flex items-center gap-3">
<span className="text-2xl">{COMPONENT_ICONS[analysis.component] || "📊"}</span>
<div>
<p className="font-medium text-gray-900">
{SERVICE_LABELS[analysis.component] || analysis.component} - {MEDIA_LABELS[analysis.media] || analysis.media}
</p>
<p className="mt-0.5 text-xs text-gray-500">
{analysis.invoice === "subscription"
? `Abonament ${analysis.plan || ""} · cost ${analysis.amount} ${analysis.amount === 1 ? "credit" : "credite"} · ${analysis.credits_available ?? 0} disponibile`
: `Factura: ${analysis.invoice} · ${analysis.posting_date}`}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{analysis.invoice === "subscription" ? (
<span className="rounded-full bg-teal-100 px-2.5 py-0.5 text-xs font-medium text-teal-700">Abonament</span>
) : (
<span className="rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-700">Disponibila</span>
)}
<svg className="h-5 w-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
))}
</div>
)}
<section className="rounded-xl border border-gray-200 bg-white p-4">
<h2 className="font-semibold text-gray-900">Rapoarte salvate</h2>
<p className="mt-1 text-xs text-gray-500">PDF-urile generate sunt salvate in ERP si pot fi descarcate oricand.</p>
{reports.length === 0 ? (
<p className="mt-4 text-sm text-gray-500">Niciun raport salvat inca.</p>
) : (
<div className="mt-4 space-y-2">
{reports.map((report) => (
<div key={report.name} className="flex items-center justify-between rounded-lg border border-gray-100 px-4 py-3">
<div>
<p className="text-sm font-medium text-gray-900">
{SERVICE_LABELS[report.component] || report.component} - {MEDIA_LABELS[report.media_type] || report.media_type}
</p>
<p className="mt-1 text-xs text-gray-500">
{report.sales_invoice ? `Factura: ${report.sales_invoice}` : "Abonament"} · Generat la {String(report.generated_at || report.creation || "").replace("T", " ").slice(0, 16)}
</p>
</div>
<a
href={`/api/analysis-report-pdf?name=${encodeURIComponent(report.name)}`}
className="rounded-lg bg-teal-600 px-3 py-2 text-xs font-semibold text-white hover:bg-teal-700"
target="_blank"
>
Download PDF
</a>
</div>
))}
</div>
)}
</section>
</div>
)}
</div>
);
}
if (phase === "input" && selected) {
const needsUrl = selected.media === "URL";
const needsFile = FILE_MEDIA_TYPES.has(selected.media);
return (
<div>
<button onClick={handleBack} className="mb-4 flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Inapoi la lista
</button>
<div className="rounded-xl border border-gray-200 p-6">
<div className="mb-6 flex items-center gap-3">
<span className="text-3xl">{COMPONENT_ICONS[selected.component] || "📊"}</span>
<div>
<h1 className="text-xl font-bold text-gray-900">
{SERVICE_LABELS[selected.component]} - {MEDIA_LABELS[selected.media]}
</h1>
<p className="text-sm text-gray-500">Factura: {selected.invoice}</p>
</div>
</div>
{error && <div className="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">{error}</div>}
{needsFile ? (
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
{selected.media === "IMAGE" ? "Imagine" : selected.media === "AUDIO" ? "Fisier audio" : "Fisier video"} de analizat
</label>
<div className="relative">
<input
type="file"
accept={ACCEPT_MAP[selected.media]}
onChange={(e) => setInputFile(e.target.files?.[0] || null)}
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm file:mr-4 file:rounded-lg file:border-0 file:bg-teal-50 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-teal-700 hover:file:bg-teal-100 focus:border-teal-500 focus:outline-none focus:ring-1 focus:ring-teal-500"
/>
</div>
{inputFile && (
<p className="mt-2 text-sm text-gray-500">
{inputFile.name} ({(inputFile.size / 1024 / 1024).toFixed(1)} MB)
</p>
)}
{(selected.media === "AUDIO" || selected.media === "VIDEO") && (
<p className="mt-2 text-xs text-gray-500">
{selected.media === "AUDIO" ? "Procesarea audio dureaza 2-5 minute (transcriere + analiza)." : "Procesarea video dureaza 5-10 minute (frames + audio + analiza)."}
</p>
)}
</div>
) : needsUrl ? (
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">URL de analizat</label>
<input
type="url"
value={inputUrl}
onChange={(e) => setInputUrl(e.target.value)}
placeholder="https://exemplu.com/articol"
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-teal-500 focus:outline-none focus:ring-1 focus:ring-teal-500"
/>
</div>
) : (
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Text de analizat</label>
<textarea
value={inputText}
onChange={(e) => setInputText(e.target.value)}
rows={8}
placeholder="Lipeste aici textul pe care doresti sa il analizezi..."
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-teal-500 focus:outline-none focus:ring-1 focus:ring-teal-500"
/>
<p className="mt-1 text-right text-xs text-gray-500">{inputText.length} caractere</p>
</div>
)}
<div className="mt-6 flex gap-3">
<button onClick={handleSubmit} className="rounded-lg bg-teal-600 px-6 py-2.5 text-sm font-semibold text-white hover:bg-teal-700">
Porneste analiza
</button>
<button onClick={handleBack} className="rounded-lg border border-gray-300 px-6 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50">
Anuleaza
</button>
</div>
<div className="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-700">
<strong>Atentie:</strong> Dupa pornirea analizei, aceasta va fi consumata din factura {selected.invoice} si nu va mai putea fi reutilizata.
</div>
</div>
</div>
);
}
if (phase === "running") {
const isMedia = selected && FILE_MEDIA_TYPES.has(selected.media);
const durationHint = selected?.media === "VIDEO"
? "Procesare video: 5-10 minute (extragere frames, transcriere audio, analiza)."
: selected?.media === "AUDIO"
? "Procesare audio: 2-5 minute (transcriere + analiza)."
: "Poate dura pana la 2 minute.";
return (
<div className="mt-12 flex flex-col items-center justify-center text-center" role="status" aria-live="polite">
<div className="h-16 w-16 animate-spin rounded-full border-4 border-teal-200 border-t-teal-600" aria-hidden="true" />
<h2 className="mt-6 text-xl font-bold text-gray-900">
{isMedia && pollCount === 0 ? "Se incarca fisierul..." : "Analiza in curs..."}
</h2>
<p className="mt-2 text-sm text-gray-500">{durationHint}</p>
{pollCount > 0 && <p className="mt-2 text-xs text-gray-500">Verificare #{pollCount}...</p>}
</div>
);
}
if (phase === "results" && result && selected) {
const verdict = (result.verdict || {}) as Record<string, unknown>;
const explanation =
(locale === "en" ? verdict.explanation_en : verdict.explanation_ro) ||
verdict.explanation_ro ||
verdict.explanation_en ||
result.conclusion ||
result.explanation;
const techniques = result.techniques as Record<string, unknown> | undefined;
return (
<div>
<div className="mb-6 flex items-center justify-between">
<button onClick={handleBack} className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Analiza noua
</button>
<button onClick={downloadPDF} className="flex items-center gap-2 rounded-lg bg-teal-600 px-4 py-2 text-sm font-semibold text-white hover:bg-teal-700">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Descarca Raport PDF
</button>
</div>
<h1 className="text-2xl font-bold text-gray-900">Rezultate analiza</h1>
<p className="mt-1 text-sm text-gray-500">
{SERVICE_LABELS[selected.component]} - {MEDIA_LABELS[selected.media]} · Factura: {selected.invoice}
</p>
{explanation ? (
<div className="mt-6 rounded-xl border-l-4 border-teal-500 bg-teal-50 p-5">
<h2 className="font-semibold text-gray-900">Concluzie</h2>
<p className="mt-2 text-sm leading-relaxed text-gray-700">{String(explanation)}</p>
</div>
) : null}
<div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<ScoreCard label="Scor Risc" value={Number(result.risk_score || 0)} max={100} />
<ScoreCard label="Incredere" value={Number(result.confidence || 0)} max={100} />
<TextCard label="Categorie" value={String(result.risk_category || "-")} />
<TextCard
label="Durata"
value={typeof result.total_duration_ms === "number" ? `${(Number(result.total_duration_ms) / 1000).toFixed(1)}s` : "-"}
/>
</div>
{(verdict.recommended_action || result.risk_level) ? (
<div className="mt-4 flex gap-2">
{verdict.recommended_action ? (
<span className="rounded-full bg-stone-100 px-3 py-1 text-xs font-semibold text-gray-700">
{String(verdict.recommended_action)}
</span>
) : null}
{result.risk_level ? (
<span className="rounded-full bg-stone-100 px-3 py-1 text-xs font-semibold text-gray-700">
{String(result.risk_level).replace(/_/g, " ")}
</span>
) : null}
</div>
) : null}
<div className="mt-6 rounded-xl border bg-white p-6">
<h3 className="font-semibold text-gray-900">
Tehnici de manipulare detectate ({String(techniques?.techniques_count || 0)})
</h3>
{Array.isArray(techniques?.techniques_detected) && techniques!.techniques_detected.length > 0 ? (
<div className="mt-3 space-y-3">
{(techniques!.techniques_detected as Array<Record<string, unknown>>).map((technique, index) => (
<div key={index} className="rounded-lg border p-4">
<p className="font-medium text-gray-900">
{formatAnalysisLabel(String(technique.name || technique.technique || "Tehnica"))}
</p>
{technique.evidence ? <p className="mt-2 text-sm italic text-gray-600">&ldquo;{String(technique.evidence)}&rdquo;</p> : null}
{technique.description && !technique.evidence ? (
<p className="mt-2 text-sm text-gray-600">{String(technique.description)}</p>
) : null}
</div>
))}
</div>
) : (
<p className="mt-3 text-sm text-green-600">Nicio tehnica de manipulare detectata.</p>
)}
</div>
<div className="mt-6 rounded-xl border bg-gray-50 px-6 py-4">
<div className="flex flex-wrap items-center gap-4 text-xs text-gray-500">
{result.session_id ? <span>Session: {String(result.session_id)}</span> : null}
{Array.isArray(result.components_run) && <span>Module: {result.components_run.join(", ")}</span>}
{typeof result.total_duration_ms === "number" && <span>Durata: {(Number(result.total_duration_ms) / 1000).toFixed(1)}s</span>}
</div>
</div>
<details className="mt-6 rounded-xl border border-gray-200">
<summary className="cursor-pointer px-5 py-3 text-sm font-medium text-gray-700 hover:bg-gray-50">Date brute (JSON)</summary>
<pre className="max-h-96 overflow-auto border-t px-5 py-4 text-xs text-gray-600">{JSON.stringify(result, null, 2)}</pre>
</details>
</div>
);
}
return null;
}
function ScoreCard({ label, value, max }: { label: string; value: number; max: number }) {
const pct = Math.min(100, Math.round(((value || 0) / max) * 100));
return (
<div className="rounded-xl border bg-gray-50 p-4 text-center">
<p className="text-xs text-gray-500">{label}</p>
<p className="mt-1 text-2xl font-bold text-gray-900">{value}</p>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-gray-200">
<div
className={`h-full rounded-full ${pct > 70 ? "bg-red-500" : pct > 40 ? "bg-yellow-500" : "bg-green-500"}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
function TextCard({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-xl border bg-gray-50 p-4 text-center">
<p className="text-xs text-gray-500">{label}</p>
<p className="mt-1 text-sm font-semibold text-gray-900">{value}</p>
</div>
);
}

View file

@ -0,0 +1,229 @@
"use client";
import { useState, useEffect } from "react";
import { useSession } from "next-auth/react";
import { fetchSubscriptionPlans, apiFetch } from "@/lib/api";
interface Plan {
name: string;
plan_name: string;
item: string;
cost: number;
currency: string;
billing_interval: string;
}
export default function CheckoutPage() {
const { data: session } = useSession();
const customerId = session?.user?.name || session?.user?.email || "";
const [step, setStep] = useState(1);
const [selected, setSelected] = useState("");
const [plans, setPlans] = useState<Plan[]>([]);
const [loading, setLoading] = useState(true);
const [processing, setProcessing] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
fetchSubscriptionPlans()
.then((data) => {
const monthly = (data as unknown as Plan[]).filter((p) => p.billing_interval === "Month");
setPlans(monthly);
if (monthly.length > 0) setSelected(monthly[0].name);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const selectedPlan = plans.find((p) => p.name === selected);
async function handleCheckout() {
if (!selectedPlan) return;
setProcessing(true);
setError("");
try {
// 1. Create Service Agreement
await apiFetch("/resource/Service%20Agreement", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
customer: customerId,
plan: selectedPlan.name,
status: "Accepted",
acceptance_date: new Date().toISOString(),
terms_version: "v1.0",
client_ip: "127.0.0.1",
agreement_html: `<p>Acord de servicii pentru planul ${selectedPlan.plan_name} - ${selectedPlan.cost} ${selectedPlan.currency}/${selectedPlan.billing_interval}</p>`,
}),
});
// 2. Update customer plan
await apiFetch(`/resource/Customer/${encodeURIComponent(customerId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
active_plan: selectedPlan.name,
iam_role: selectedPlan.cost === 0 ? "free_tier" : selectedPlan.cost >= 499 ? "enterprise_tier" : "paid_tier",
}),
});
// 3. Create invoice
await apiFetch("/resource/Sales%20Invoice", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
customer: customerId,
naming_series: "DIDI-INV-.YYYY.-.#####",
items: [{ item_code: selectedPlan.item, qty: 1, rate: selectedPlan.cost }],
taxes: [{ charge_type: "On Net Total", account_head: "TVA Colectata - TC", description: "TVA 19%", rate: 19 }],
}),
});
setStep(3);
} catch (err) {
setError("A aparut o eroare. Te rugam sa incerci din nou.");
console.error(err);
} finally {
setProcessing(false);
}
}
if (loading) {
return (
<div className="mx-auto max-w-2xl">
<h1 className="text-2xl font-bold text-gray-900">Checkout</h1>
<p className="mt-4 text-gray-500">Se incarca planurile...</p>
</div>
);
}
// Step 3: Success
if (step === 3) {
return (
<div className="mx-auto max-w-lg text-center">
<div className="rounded-2xl border bg-green-50 p-8">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-green-100 text-3xl">
</div>
<h1 className="mt-4 text-2xl font-bold text-gray-900">Abonament activat!</h1>
<p className="mt-2 text-gray-600">
Planul <strong>{selectedPlan?.plan_name}</strong> a fost activat. Factura a fost generata in ERPNext.
</p>
<a
href="/dashboard"
className="mt-6 inline-block rounded-lg bg-teal-600 px-6 py-2.5 text-sm font-semibold text-white hover:bg-teal-700"
>
Inapoi la dashboard
</a>
</div>
</div>
);
}
return (
<div className="mx-auto max-w-2xl">
<h1 className="text-2xl font-bold text-gray-900">Checkout</h1>
{/* Steps indicator */}
<div className="mt-6 flex gap-2">
{["Selectare plan", "Date facturare", "Confirmare"].map((s, i) => (
<div
key={s}
className={`flex-1 rounded-lg py-2 text-center text-xs font-medium ${
step === i + 1 ? "bg-teal-600 text-white" : "bg-gray-100 text-gray-500"
}`}
>
{s}
</div>
))}
</div>
{error && (
<div className="mt-4 rounded-lg bg-red-50 p-3 text-sm text-red-700">{error}</div>
)}
{/* Step 1: Select plan */}
{step === 1 && (
<div className="mt-8 space-y-3">
{plans.map((p) => (
<button
key={p.name}
onClick={() => setSelected(p.name)}
className={`flex w-full items-center justify-between rounded-xl border p-4 text-left transition ${
selected === p.name ? "border-teal-600 ring-1 ring-teal-600" : "hover:border-gray-300"
}`}
>
<div>
<p className="font-semibold">{p.plan_name.replace(" - Lunar", "")}</p>
<p className="text-sm text-gray-500">
{p.cost === 0 ? "Gratuit" : `${p.cost} ${p.currency} / luna`}
</p>
</div>
{selected === p.name && (
<svg className="h-5 w-5 text-teal-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
)}
</button>
))}
<button
onClick={() => setStep(2)}
className="mt-4 w-full rounded-lg bg-teal-600 py-3 text-sm font-semibold text-white hover:bg-teal-700"
>
Continua
</button>
</div>
)}
{/* Step 2: Billing + confirm */}
{step === 2 && selectedPlan && (
<div className="mt-8 space-y-4">
<div className="rounded-xl border bg-gray-50 p-4">
<p className="text-sm text-gray-500">Plan selectat</p>
<p className="text-lg font-bold">{selectedPlan.plan_name}</p>
<p className="text-sm text-gray-600">
{selectedPlan.cost === 0 ? "Gratuit" : `${selectedPlan.cost} RON + TVA 19% = ${(selectedPlan.cost * 1.19).toFixed(2)} RON / luna`}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Companie</label>
<input
type="text"
defaultValue={customerId}
disabled
className="mt-1 block w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-2.5 text-sm text-gray-500"
/>
</div>
<label className="flex items-start gap-2">
<input type="checkbox" defaultChecked className="mt-1" />
<span className="text-xs text-gray-600">
Accept{" "}
<a href="/terms" target="_blank" className="text-teal-600 hover:underline">
Termenii si Conditiile
</a>{" "}
serviciului
</span>
</label>
<div className="flex gap-3">
<button
onClick={() => setStep(1)}
className="flex-1 rounded-lg border py-3 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Inapoi
</button>
<button
onClick={handleCheckout}
disabled={processing}
className="flex-1 rounded-lg bg-teal-600 py-3 text-sm font-semibold text-white hover:bg-teal-700 disabled:opacity-50"
>
{processing ? "Se proceseaza..." : selectedPlan.cost === 0 ? "Activeaza gratuit" : `Plateste ${(selectedPlan.cost * 1.19).toFixed(2)} RON`}
</button>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,59 @@
import Link from "next/link";
import { finalizeStripeCheckout } from "@/lib/stripe-fulfillment";
export default async function CheckoutSuccessPage({
searchParams,
}: {
searchParams: Promise<{ session_id?: string }>;
}) {
const { session_id } = await searchParams;
let result: Awaited<ReturnType<typeof finalizeStripeCheckout>> | null = null;
if (session_id) {
try {
result = await finalizeStripeCheckout(session_id);
console.log("[CHECKOUT SUCCESS] result:", JSON.stringify(result));
} catch (e) {
console.error("[CHECKOUT SUCCESS] error:", e);
}
}
const invoiceName =
result && "invoiceName" in result && result.invoiceName ? result.invoiceName : null;
const isReady = result?.status === "fulfilled" || result?.status === "already_processed";
return (
<div className="mx-auto max-w-lg text-center">
<div className="rounded-2xl border bg-green-50 p-8">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-green-100 text-4xl">
</div>
<h1 className="mt-4 text-2xl font-bold text-gray-900">Plata confirmata!</h1>
<p className="mt-2 text-gray-600">
{isReady
? "Serviciul a fost achizitionat cu succes. Factura a fost generata si marcata ca platita."
: "Plata a fost confirmata de Stripe. Sincronizarea cu ERP este in curs."}
</p>
{session_id && (
<p className="mt-2 text-xs text-gray-500">Referinta: {session_id}</p>
)}
{invoiceName && (
<p className="mt-1 text-xs text-gray-500">Factura ERP: {invoiceName}</p>
)}
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:justify-center">
<Link
href="/dashboard/invoices"
className="rounded-lg bg-teal-600 px-6 py-2.5 text-sm font-semibold text-white hover:bg-teal-700"
>
Vezi facturi
</Link>
<Link
href="/dashboard"
className="rounded-lg border px-6 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Inapoi la dashboard
</Link>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,130 @@
import { auth } from "@/lib/auth";
export const dynamic = "force-dynamic";
type CreditsData = {
creditsRemained: number;
creditsSpent: number;
planName: string;
creditsPerCycle: number;
};
type UsageItem = {
date: string;
type: string;
credits: number;
};
async function fetchCredits(): Promise<{ credits: CreditsData | null; usage: UsageItem[] }> {
const session = await auth();
const accessToken = (session as unknown as Record<string, unknown>)?.accessToken as string;
const userId = session?.user?.id;
if (!accessToken || !userId) return { credits: null, usage: [] };
const FRAMEWORK_URL = process.env.DIDI_FRAMEWORK_URL || "http://didi-framework:3005";
const DIDI_API = process.env.DIDI_API_URL || "http://didi-agent-v3:24803/api";
// Credits summary
const cr = await fetch(`${FRAMEWORK_URL}/api/auth/credits`, {
headers: { Authorization: `Bearer ${accessToken}` },
cache: "no-store",
}).catch(() => null);
const creditsJson = cr?.ok ? await cr.json() : null;
const credits = creditsJson?.data || null;
// Usage history (from agent-v3)
const hr = await fetch(`${DIDI_API}/v3/pipeline/history?user_id=${encodeURIComponent(userId)}&limit=20`, {
headers: { Authorization: `Bearer ${accessToken}` },
cache: "no-store",
}).catch(() => null);
const histJson = hr?.ok ? await hr.json() : null;
const items = histJson?.data?.items || [];
const mediaCost: Record<string, number> = { text: 1, url: 1, image: 2, audio: 3, video: 5 };
const usage: UsageItem[] = items.map((item: Record<string, unknown>) => ({
date: String(item.created_at || item.started_at || "").substring(0, 10),
type: `Analiza ${item.input_type || "necunoscut"} (${(item.components_run as string[] | undefined)?.join(", ") || "techniques"})`,
credits: mediaCost[String(item.input_type || "text")] || 1,
}));
return { credits, usage };
}
export default async function CreditsPage() {
const { credits, usage } = await fetchCredits();
if (!credits) {
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">Credite & Consum</h1>
<p className="mt-4 text-sm text-red-600">Nu am putut prelua creditele. Asigura-te ca esti autentificat.</p>
</div>
);
}
const available = Number(credits.creditsRemained || 0);
const total = Number(credits.creditsPerCycle || 100);
const spent = Number(credits.creditsSpent || 0);
const pct = total > 0 ? Math.round((available / total) * 100) : 0;
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">Credite & Consum</h1>
<p className="mt-1 text-sm text-gray-500">
Plan: <span className="font-medium text-gray-700">{credits.planName}</span> &middot; creditele si istoricul de utilizare.
</p>
{/* Credits overview */}
<div className="mt-6 grid gap-4 sm:grid-cols-2">
<div className="rounded-xl border p-6">
<p className="text-sm text-gray-500">Credite disponibile</p>
<p className="mt-2 text-4xl font-bold text-gray-900">
{available} <span className="text-lg font-normal text-gray-500">/ {total}</span>
</p>
<div className="mt-4 h-3 overflow-hidden rounded-full bg-gray-200">
<div className="h-full rounded-full bg-teal-600" style={{ width: `${pct}%` }} />
</div>
<p className="mt-2 text-xs text-gray-500">Plan: {credits.planName}</p>
</div>
<div className="rounded-xl border p-6">
<p className="text-sm text-gray-500">Total consumat (cumulativ)</p>
<p className="mt-2 text-4xl font-bold text-gray-900">{spent}</p>
<p className="mt-2 text-xs text-gray-500">credite folosite de la inregistrare</p>
</div>
</div>
{/* Usage history */}
<div className="mt-8">
<h2 className="text-lg font-semibold text-gray-900">Istoric utilizare ({usage.length} analize)</h2>
<div className="mt-4 overflow-hidden rounded-lg border">
<table className="w-full text-left text-sm">
<thead className="bg-gray-50 text-xs uppercase text-gray-500">
<tr>
<th className="px-4 py-3">Data</th>
<th className="px-4 py-3">Tip analiza</th>
<th className="px-4 py-3">Credite</th>
</tr>
</thead>
<tbody>
{usage.length === 0 ? (
<tr>
<td colSpan={3} className="px-4 py-6 text-center text-gray-500">
Nicio analiza inregistrata. Achizitioneaza si ruleaza prima ta analiza.
</td>
</tr>
) : (
usage.map((u, i) => (
<tr key={i} className="border-t">
<td className="px-4 py-3 text-gray-500">{u.date}</td>
<td className="px-4 py-3">{u.type}</td>
<td className="px-4 py-3 font-medium text-red-600">-{u.credits}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,71 @@
import { getInvoices } from "@/lib/erpnext";
import { getCurrentUser } from "@/lib/auth";
export const dynamic = "force-dynamic";
export default async function InvoicesPage() {
const user = await getCurrentUser();
const customerId = user?.customerId || "";
let invoices: Record<string, unknown>[] = [];
try {
const res = customerId ? await getInvoices(customerId) : { data: [] };
invoices = res.data;
} catch {
// ERPNext unavailable
}
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">Facturi</h1>
<p className="mt-1 text-sm text-gray-500">Istoricul facturilor emise pentru contul tau.</p>
{invoices.length === 0 ? (
<div className="mt-8 rounded-xl border border-dashed p-8 text-center text-gray-500">
Nicio factura emisa inca.
</div>
) : (
<div className="mt-6 overflow-hidden rounded-lg border">
<table className="w-full text-left text-sm">
<thead className="bg-gray-50 text-xs uppercase text-gray-500">
<tr>
<th className="px-4 py-3">Numar factura</th>
<th className="px-4 py-3">Data</th>
<th className="px-4 py-3">Suma</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Actiuni</th>
</tr>
</thead>
<tbody>
{invoices.map((inv) => (
<tr key={String(inv.name)} className="border-t">
<td className="px-4 py-3 font-medium">{String(inv.name)}</td>
<td className="px-4 py-3 text-gray-500">{String(inv.posting_date)}</td>
<td className="px-4 py-3">{String(inv.grand_total)} {String(inv.currency)}</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${
inv.status === "Paid" ? "bg-green-100 text-green-700" :
inv.status === "Overdue" ? "bg-red-100 text-red-700" :
"bg-gray-100 text-gray-600"
}`}>
{inv.status === "Paid" ? "Platita" : inv.status === "Draft" ? "Draft" : String(inv.status)}
</span>
</td>
<td className="px-4 py-3">
<a
href={`/api/invoice-pdf?name=${encodeURIComponent(String(inv.name))}`}
className="text-sm text-teal-600 hover:underline"
target="_blank"
>
Download PDF
</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,192 @@
import Link from "next/link";
import { getInvoiceAnalysisStats, getInvoices } from "@/lib/erpnext";
import { getCurrentUser } from "@/lib/auth";
export const dynamic = "force-dynamic";
export default async function DashboardOverview() {
const user = await getCurrentUser();
const userName = user?.name || "User";
const customerId = user?.customerId || "";
let invoices: Record<string, unknown>[] = [];
let purchasedAnalyses = 0;
let availableAnalyses = 0;
if (customerId) {
try {
const invRes = await getInvoices(customerId);
invoices = invRes.data;
const stats = await getInvoiceAnalysisStats(customerId);
purchasedAnalyses = stats.confirmed;
availableAnalyses = stats.available;
} catch {}
}
return (
<div className="space-y-6">
<section className="overflow-hidden rounded-[28px] border border-black/5 bg-white shadow-sm">
<div className="bg-gradient-to-r from-teal-600 to-emerald-500 px-6 py-7 text-white lg:px-8">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-white/70">Dashboard</p>
<h1 className="mt-2 text-2xl font-semibold tracking-tight lg:text-3xl">Bine ai venit, {userName}.</h1>
<p className="mt-2 max-w-2xl text-sm text-white/80">
Vezi rapid istoricul achizitiilor si porneste o analiza noua fara pasi inutili.
</p>
</div>
<div className="grid gap-4 px-6 py-5 lg:grid-cols-[minmax(0,1fr)_240px] lg:px-8">
<div className="grid gap-4 sm:grid-cols-2">
<Card
eyebrow="Achizitii"
title="Analize achizitionate"
value={String(purchasedAnalyses)}
sub="Toate serviciile one-time confirmate in cont"
href="/dashboard/invoices"
/>
<Card
eyebrow="Disponibile"
title="Analize disponibile"
value={String(availableAnalyses)}
sub="Servicii platite si neconsumate, gata de pornire"
href="/dashboard/analize"
/>
<Link
href="/dashboard/achizitioneaza"
className="group rounded-2xl border border-dashed border-teal-300 bg-teal-50/70 p-5 transition-all hover:border-teal-400 hover:bg-teal-50 sm:col-span-2"
>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white text-lg font-semibold text-teal-700 shadow-sm">
+
</div>
<p className="mt-4 text-sm font-semibold text-gray-900">Achizitioneaza o analiza</p>
<p className="mt-1 text-sm leading-relaxed text-gray-500">
Porneste rapid un serviciu nou pentru text, imagine, audio sau video.
</p>
<p className="mt-4 text-sm font-medium text-teal-700">Deschide catalogul</p>
</Link>
</div>
<div className="rounded-2xl border border-black/5 bg-stone-50 p-5">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500">Rezumat</p>
<div className="mt-4 space-y-4">
<div>
<p className="text-2xl font-semibold tracking-tight text-gray-900">{invoices.length}</p>
<p className="text-sm text-gray-500">facturi totale in cont</p>
</div>
<div className="h-px bg-black/5" />
<div>
<p className="text-2xl font-semibold tracking-tight text-gray-900">{purchasedAnalyses}</p>
<p className="text-sm text-gray-500">achizitii confirmate</p>
</div>
<div className="h-px bg-black/5" />
<div>
<p className="text-2xl font-semibold tracking-tight text-gray-900">{availableAnalyses}</p>
<p className="text-sm text-gray-500">analize disponibile acum</p>
</div>
</div>
</div>
</div>
</section>
{invoices.length > 0 && (
<section className="rounded-[28px] border border-black/5 bg-white p-5 shadow-sm lg:p-6">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold text-gray-900">Ultimele facturi</h2>
<Link
href="/dashboard/invoices"
className="rounded-full bg-stone-100 px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-stone-200"
>
Vezi toate
</Link>
</div>
<div className="mt-4 overflow-hidden rounded-2xl border border-black/5">
<table className="w-full text-left text-sm">
<thead className="bg-stone-50 text-xs uppercase tracking-[0.18em] text-gray-500">
<tr>
<th className="px-4 py-3">Numar</th>
<th className="px-4 py-3">Data</th>
<th className="px-4 py-3">Suma</th>
<th className="px-4 py-3">Status</th>
</tr>
</thead>
<tbody>
{invoices.slice(0, 5).map((inv) => (
<tr key={String(inv.name)} className="border-t border-black/5 bg-white">
<td className="px-4 py-3 font-medium text-gray-900">{String(inv.name)}</td>
<td className="px-4 py-3 text-gray-500">{String(inv.posting_date)}</td>
<td className="px-4 py-3 text-gray-700">
{String(inv.grand_total)} {String(inv.currency)}
</td>
<td className="px-4 py-3">
<StatusBadge status={String(inv.status)} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
{invoices.length === 0 && (
<section className="rounded-[28px] border border-dashed border-teal-200 bg-white p-10 text-center shadow-sm">
<p className="text-base font-medium text-gray-900">Nicio achizitie inca.</p>
<p className="mt-2 text-sm text-gray-500">
Dupa prima comanda, istoricul si documentele vor aparea automat aici.
</p>
<Link
href="/pricing"
className="mt-4 inline-flex rounded-full bg-teal-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-teal-700"
>
Achizitioneaza prima analiza
</Link>
</section>
)}
</div>
);
}
function Card({
eyebrow,
title,
value,
sub,
href,
}: {
eyebrow: string;
title: string;
value: string;
sub: string;
href: string;
}) {
return (
<Link
href={href}
className="rounded-2xl border border-black/5 bg-white p-5 transition-all hover:-translate-y-0.5 hover:shadow-md"
>
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-gray-500">{eyebrow}</p>
<p className="mt-3 text-sm font-medium text-gray-500">{title}</p>
<p className="mt-1 text-4xl font-semibold tracking-tight text-gray-900">{value}</p>
<p className="mt-3 text-sm leading-relaxed text-gray-500">{sub}</p>
</Link>
);
}
function StatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
Paid: "bg-green-100 text-green-700",
Unpaid: "bg-yellow-100 text-yellow-700",
Draft: "bg-gray-100 text-gray-600",
Overdue: "bg-red-100 text-red-700",
};
const labels: Record<string, string> = {
Paid: "Platita",
Unpaid: "Neplatita",
Draft: "Draft",
Overdue: "Restanta",
};
return (
<span className={`rounded-full px-2.5 py-1 text-xs font-medium ${colors[status] || colors.Draft}`}>
{labels[status] || status}
</span>
);
}

View file

@ -0,0 +1,202 @@
"use client";
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import { updateCustomer } from "@/lib/api";
export default function ProfilePage() {
const { data: session } = useSession();
const userName = session?.user?.name || "";
const userEmail = session?.user?.email || "";
const [customer, setCustomer] = useState<Record<string, unknown> | null>(null);
const [customerName, setCustomerName] = useState("");
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/customer/me")
.then((res) => (res.ok ? res.json() : null))
.then((res) => {
if (res?.data) {
setCustomer(res.data);
setCustomerName(String(res.data.name || ""));
}
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
async function handleSave(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setSaving(true);
setSaved(false);
const form = new FormData(e.currentTarget);
try {
await updateCustomer(customerName, {
customer_name: form.get("company") as string,
tax_id: form.get("tax_id") as string,
});
setSaved(true);
} catch {
alert("Eroare la salvare");
} finally {
setSaving(false);
}
}
if (loading) {
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">Profil</h1>
<p className="mt-4 text-gray-500">Se incarca...</p>
</div>
);
}
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">Profil</h1>
<p className="mt-1 text-sm text-gray-500">Gestioneaza datele tale personale.</p>
<form onSubmit={handleSave} className="mt-6 max-w-lg space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Nume</label>
<input
type="text"
defaultValue={userName}
disabled
className="mt-1 block w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-2.5 text-sm text-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Email</label>
<input
type="email"
defaultValue={userEmail}
disabled
className="mt-1 block w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-2.5 text-sm text-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Companie</label>
<input
name="company"
type="text"
defaultValue={String(customer?.customer_name || "")}
className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">CUI</label>
<input
name="tax_id"
type="text"
defaultValue={String(customer?.tax_id || "")}
className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm"
/>
</div>
<div className="flex items-center gap-3">
<button
type="submit"
disabled={saving}
className="rounded-lg bg-teal-600 px-6 py-2.5 text-sm font-semibold text-white hover:bg-teal-700 disabled:opacity-50"
>
{saving ? "Se salveaza..." : "Salveaza"}
</button>
{saved && <span className="text-sm text-green-600">Salvat!</span>}
</div>
</form>
{/* GDPR section */}
<GdprActions />
</div>
);
}
function GdprActions() {
const [busy, setBusy] = useState<string | null>(null);
const [msg, setMsg] = useState<string | null>(null);
function exportData() {
setBusy("export");
setMsg(null);
// Open download in new tab — server returns Content-Disposition: attachment
window.open("/api/gdpr/export", "_blank");
setTimeout(() => {
setBusy(null);
setMsg("Datele tale au fost descarcate ca fisier JSON.");
}, 1500);
}
async function requestDelete() {
const ok = confirm(
"Esti SIGUR ca vrei sa stergi contul?\n\n" +
"- Analizele si rapoartele tale vor fi anonimizate\n" +
"- Facturile fiscale raman in arhiva 10 ani (cerinta legala RO)\n" +
"- Datele personale vor fi sterse in 30 zile\n" +
"- Actiunea este IREVERSIBILA dupa 30 zile\n\n" +
"Continui?"
);
if (!ok) return;
setBusy("delete");
setMsg(null);
try {
const res = await fetch("/api/gdpr/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: true, reason: "Solicitare self-service din dashboard" }),
});
const data = await res.json();
if (res.ok) {
setMsg(data.message || "Cererea a fost inregistrata.");
} else {
setMsg(`Eroare: ${data.error}`);
}
} catch (e) {
setMsg(`Eroare: ${e instanceof Error ? e.message : "necunoscuta"}`);
} finally {
setBusy(null);
}
}
return (
<div className="mt-10 border-t pt-8">
<h2 className="text-lg font-semibold text-gray-900">Protectia datelor (GDPR)</h2>
<p className="mt-2 text-sm text-gray-500">
Exercita-ti drepturile conform Regulamentului (UE) 2016/679 (GDPR).
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<button
disabled={!!busy}
onClick={exportData}
className="rounded-lg border p-4 text-left text-sm hover:bg-gray-50 disabled:opacity-50"
>
<div className="font-semibold text-gray-900">Exporta datele mele</div>
<div className="mt-1 text-xs text-gray-500">
Art. 20 GDPR portabilitate. Descarca toate datele tale (profil, facturi, plati, analize) in format JSON.
</div>
</button>
<button
disabled={!!busy}
onClick={requestDelete}
className="rounded-lg border border-red-200 p-4 text-left text-sm hover:bg-red-50 disabled:opacity-50"
>
<div className="font-semibold text-red-700">Solicita stergerea contului</div>
<div className="mt-1 text-xs text-red-600">
Art. 17 GDPR dreptul la stergere. Cererea va fi procesata in 30 zile.
</div>
</button>
</div>
{msg && (
<p className={`mt-4 text-sm ${msg.startsWith("Eroare") ? "text-red-600" : "text-green-700"}`}>
{msg}
</p>
)}
</div>
);
}

View file

@ -0,0 +1,106 @@
"use client";
import { useState } from "react";
type Plan = { key: string; label: string; price: string };
const PLANS: Plan[] = [
{ key: "paid-monthly", label: "Paid - Lunar", price: "99 RON/luna" },
{ key: "paid-yearly", label: "Paid - Anual", price: "999 RON/an" },
{ key: "enterprise-monthly", label: "Enterprise - Lunar", price: "499 RON/luna" },
{ key: "enterprise-yearly", label: "Enterprise - Anual", price: "4990 RON/an" },
];
export default function SubscriptionActions({
hasActiveSubscription,
currentPlanKey,
}: {
hasActiveSubscription: boolean;
currentPlanKey?: string;
}) {
const [loading, setLoading] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
async function callAction(action: string, planKey?: string) {
setLoading(planKey || action);
setMessage(null);
try {
const res = await fetch("/api/subscription/manage", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, planKey }),
});
const data = await res.json();
if (!res.ok) {
setMessage(`Eroare: ${data.error || "necunoscuta"}`);
} else if (data.checkout_url) {
window.location.href = data.checkout_url;
} else {
setMessage(data.message || "OK — paginile se vor actualiza in scurt timp.");
setTimeout(() => window.location.reload(), 2500);
}
} catch (e) {
setMessage(`Eroare: ${e instanceof Error ? e.message : "necunoscuta"}`);
} finally {
setLoading(null);
}
}
return (
<div className="mt-6 rounded-xl border p-6">
<h3 className="font-semibold text-gray-900">Gestioneaza abonamentul</h3>
<p className="mt-1 text-sm text-gray-500">
Upgrade sau downgrade se face cu pro-rata (Stripe calculeaza diferenta automat).
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{PLANS.map((p) => {
const isCurrent = currentPlanKey === p.key;
const action = hasActiveSubscription
? p.key.includes("enterprise") || (p.key.startsWith("paid") && currentPlanKey?.startsWith("paid") && p.key.includes("yearly"))
? "upgrade"
: "downgrade"
: "upgrade";
return (
<button
key={p.key}
disabled={isCurrent || !!loading}
onClick={() => callAction(action, p.key)}
className={`rounded-lg border px-4 py-3 text-left text-sm transition ${
isCurrent
? "border-teal-600 bg-teal-50 text-teal-900"
: "border-gray-200 hover:border-teal-400 hover:bg-gray-50"
} disabled:opacity-50`}
>
<div className="font-semibold">{p.label}</div>
<div className="mt-1 text-xs text-gray-500">{p.price}</div>
<div className="mt-2 text-xs font-medium text-teal-600">
{isCurrent ? "Planul curent" : loading === p.key ? "Se proceseaza..." : action === "upgrade" ? "Activeaza" : "Treci pe acest plan"}
</div>
</button>
);
})}
</div>
{hasActiveSubscription && (
<button
disabled={!!loading}
onClick={() => {
if (confirm("Sigur vrei sa anulezi abonamentul? Vei avea acces pana la sfarsitul perioadei platite.")) {
callAction("cancel");
}
}}
className="mt-4 rounded-lg border border-red-200 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-50 disabled:opacity-50"
>
{loading === "cancel" ? "Se anuleaza..." : "Anuleaza abonamentul"}
</button>
)}
{message && (
<p className={`mt-4 text-sm ${message.startsWith("Eroare") ? "text-red-600" : "text-green-700"}`}>
{message}
</p>
)}
</div>
);
}

View file

@ -0,0 +1,145 @@
import { getCustomerSubscription, getCustomer } from "@/lib/erpnext";
import { getCurrentUser } from "@/lib/auth";
import SubscriptionActions from "./SubscriptionActions";
export const dynamic = "force-dynamic";
export default async function SubscriptionPage() {
const user = await getCurrentUser();
const customerId = user?.customerId || "";
let subscription: Record<string, unknown> | null = null;
let customer: Record<string, unknown> | null = null;
if (customerId) {
try {
const custRes = await getCustomer(customerId);
customer = custRes.data;
} catch { /* customer not found */ }
try {
subscription = await getCustomerSubscription(customerId);
} catch { /* no subscription yet */ }
}
const planName = customer?.active_plan
? String(customer.active_plan).replace(" - Lunar", "").replace(" - Anual", "")
: "Niciun plan activ";
const iamRole = customer?.iam_role ? String(customer.iam_role) : "free_tier";
const activationDate = customer?.plan_activation_date
? String(customer.plan_activation_date)
: "N/A";
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">Abonament</h1>
{/* Current plan */}
<div className="mt-6 rounded-xl border p-6">
<div className="flex items-start justify-between">
<div>
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${
iamRole !== "free_tier"
? "bg-teal-100 text-teal-700"
: "bg-gray-100 text-gray-600"
}`}>
{iamRole !== "free_tier" ? "Activ" : "Free"}
</span>
<h2 className="mt-3 text-xl font-bold text-gray-900">{planName}</h2>
<p className="mt-1 text-sm text-gray-500">
Rol IAM: {iamRole}
</p>
</div>
</div>
<div className="mt-6 grid gap-4 border-t pt-6 sm:grid-cols-3">
<div>
<p className="text-xs text-gray-500">Data activarii</p>
<p className="mt-1 text-sm font-medium">{activationDate}</p>
</div>
<div>
<p className="text-xs text-gray-500">Customer ID (ERPNext)</p>
<p className="mt-1 text-sm font-medium">{customerId}</p>
</div>
<div>
<p className="text-xs text-gray-500">DiDi User ID</p>
<p className="mt-1 text-sm font-medium">{String(customer?.didi_user_id || "N/A")}</p>
</div>
</div>
</div>
{/* Subscription details from ERPNext */}
{subscription && (
<div className="mt-6 rounded-xl border p-6">
<h3 className="font-semibold text-gray-900">Detalii subscriptie ERPNext</h3>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<p className="text-xs text-gray-500">Subscription ID</p>
<p className="mt-1 text-sm font-medium">{String(subscription.name)}</p>
</div>
<div>
<p className="text-xs text-gray-500">Status</p>
<p className="mt-1 text-sm font-medium">{String(subscription.status)}</p>
</div>
<div>
<p className="text-xs text-gray-500">Perioada curenta</p>
<p className="mt-1 text-sm font-medium">
{String(subscription.current_invoice_start || "N/A")} - {String(subscription.current_invoice_end || "N/A")}
</p>
</div>
<div>
<p className="text-xs text-gray-500">Plan</p>
<p className="mt-1 text-sm font-medium">{String(subscription.plan || "N/A")}</p>
</div>
</div>
</div>
)}
<SubscriptionActions
hasActiveSubscription={iamRole !== "free_tier"}
currentPlanKey={customer?.active_plan ? String(customer.active_plan) : undefined}
/>
{/* Features based on role */}
<div className="mt-6 rounded-xl border p-6">
<h3 className="font-semibold text-gray-900">Functionalitati incluse</h3>
<ul className="mt-4 space-y-2">
{getFeaturesForRole(iamRole).map((f) => (
<li key={f} className="flex items-center gap-2 text-sm text-gray-600">
<svg className="h-4 w-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
{f}
</li>
))}
</ul>
</div>
</div>
);
}
function getFeaturesForRole(role: string): string[] {
// Credite per analiza: text/url=1, imagine=2, audio=3, video=5
const features: Record<string, string[]> = {
free_tier: [
"5 credite pe luna",
"Toate tipurile de continut (text / URL / imagine / audio / video)",
"Dashboard + export PDF",
"Stocare 1 GB",
],
paid_tier: [
"250 credite pe luna",
"Toate tipurile de continut",
"Dashboard + export PDF",
"Stocare 10 GB",
"Suport email zile lucratoare 9-18",
],
enterprise_tier: [
"6000 credite pe luna",
"Toate tipurile de continut",
"Acces API REST cu JWT",
"Stocare nelimitata",
"Suport email prioritar (raspuns in 8h ore lucratoare)",
],
};
return features[role] || features.free_tier;
}

View file

@ -0,0 +1,53 @@
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import Link from "next/link";
import DashboardSidebar from "@/components/layout/DashboardSidebar";
import SignOutButton from "@/components/ui/SignOutButton";
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const session = await auth();
if (!session) redirect("/login");
const user = session.user;
const displayName = user?.name || user?.email || "User";
return (
<div className="flex min-h-screen flex-col bg-stone-50">
<a href="#main-content" className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[100] focus:rounded-lg focus:bg-teal-600 focus:px-4 focus:py-2 focus:text-white focus:outline-none">
Salt la continut
</a>
<header className="sticky top-0 z-50 flex h-16 items-center justify-between border-b border-black/5 bg-white/90 px-4 backdrop-blur lg:px-6">
<Link href="/dashboard" className="flex items-center">
<img src="/logo.png" alt="Clossers" className="h-7" />
</Link>
<div className="flex items-center gap-3 rounded-full border border-black/5 bg-white px-2.5 py-1.5 shadow-sm">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-teal-100 text-sm font-semibold text-teal-700">
{displayName[0]?.toUpperCase() || "U"}
</div>
<span className="hidden pr-1 text-sm font-medium text-gray-700 sm:inline">{displayName}</span>
</div>
</header>
<div className="flex flex-1 flex-col lg:flex-row">
<DashboardSidebar userName={displayName} />
<div className="flex gap-1 overflow-x-auto border-b border-black/5 bg-white px-4 py-2 lg:hidden">
{[
{ href: "/dashboard", label: "Overview" },
{ href: "/dashboard/analize", label: "Analize" },
{ href: "/dashboard/invoices", label: "Facturi" },
{ href: "/dashboard/subscription", label: "Abonament" },
{ href: "/dashboard/profile", label: "Profil" },
].map((l) => (
<Link key={l.href} href={l.href} className="whitespace-nowrap rounded-full px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-stone-100">
{l.label}
</Link>
))}
<SignOutButton className="ml-auto whitespace-nowrap rounded-full border border-black/5 px-3 py-1.5 text-xs font-medium text-gray-500 hover:bg-stone-100" />
</div>
<main id="main-content" className="flex-1 p-4 lg:p-6" tabIndex={-1}>{children}</main>
</div>
</div>
);
}

View file

@ -0,0 +1,108 @@
import type { Metadata } from "next";
import { getWebsiteContent } from "@/lib/erpnext";
export const metadata: Metadata = {
title: "Despre - Clossers si platforma DiDi",
description: "TOP CLOSSERS SRL dezvolta platforma DiDi pentru combaterea dezinformarii prin inteligenta artificiala.",
};
export default async function AboutPage() {
let sections: Record<string, Record<string, string>> = {};
try {
const data = await getWebsiteContent("about");
for (const item of data) {
sections[item.section_key] = item;
}
} catch {}
const company = parseJson(sections.company?.extra_data);
return (
<>
{/* Hero */}
<section className="bg-gradient-to-b from-teal-50 to-white py-20">
<div className="mx-auto max-w-3xl px-4 text-center">
<div
className="prose prose-teal prose-lg mx-auto"
dangerouslySetInnerHTML={{
__html: sections.hero?.content_ro || "<h1>Despre Clossers</h1>",
}}
/>
</div>
</section>
{/* Mission */}
<section className="py-16">
<div className="mx-auto max-w-3xl px-4">
<div
className="prose prose-teal mx-auto"
dangerouslySetInnerHTML={{
__html: sections.mission?.content_ro || "",
}}
/>
</div>
</section>
{/* Technology */}
<section className="bg-gray-50 py-16">
<div className="mx-auto max-w-3xl px-4">
<div
className="prose prose-teal mx-auto"
dangerouslySetInnerHTML={{
__html: sections.technology?.content_ro || "",
}}
/>
</div>
</section>
{/* PNRR */}
<section className="py-16">
<div className="mx-auto max-w-3xl px-4">
<div
className="prose prose-teal mx-auto"
dangerouslySetInnerHTML={{
__html: sections.project?.content_ro || "",
}}
/>
</div>
</section>
{/* Company info */}
{company && (
<section className="bg-gray-50 py-16">
<div className="mx-auto max-w-3xl px-4">
<h2 className="text-2xl font-bold text-gray-900">Date companie</h2>
<div className="mt-6 grid gap-4 sm:grid-cols-2">
<div className="rounded-xl border bg-white p-5">
<p className="text-xs text-gray-500">Denumire</p>
<p className="mt-1 font-semibold">{company.name}</p>
</div>
<div className="rounded-xl border bg-white p-5">
<p className="text-xs text-gray-500">CUI</p>
<p className="mt-1 font-semibold">{company.cui}</p>
</div>
<div className="rounded-xl border bg-white p-5">
<p className="text-xs text-gray-500">Reg. Comert</p>
<p className="mt-1 font-semibold">{company.reg_com}</p>
</div>
<div className="rounded-xl border bg-white p-5">
<p className="text-xs text-gray-500">Email</p>
<p className="mt-1 font-semibold">{company.email}</p>
</div>
<div className="rounded-xl border bg-white p-5 sm:col-span-2">
<p className="text-xs text-gray-500">Adresa</p>
<p className="mt-1 font-semibold">{company.address}</p>
</div>
</div>
</div>
</section>
)}
</>
);
}
function parseJson(str?: string) {
if (!str) return null;
try { return JSON.parse(str); } catch { return null; }
}

View file

@ -0,0 +1,47 @@
import type { Metadata } from "next";
import ContactForm from "@/components/forms/ContactForm";
export const metadata: Metadata = {
title: "Contact",
description: "Contacteaza echipa DiDi pentru intrebari sau suport.",
};
export default function ContactPage() {
return (
<section className="py-20">
<div className="mx-auto max-w-3xl px-4 sm:px-6">
<div className="text-center">
<h1 className="text-4xl font-bold text-gray-900">Contacteaza-ne</h1>
<p className="mt-4 text-lg text-gray-600">
Ai intrebari? Echipa noastra iti sta la dispozitie.
</p>
</div>
<div className="mt-12 grid gap-12 md:grid-cols-2">
{/* Contact Form */}
<ContactForm />
{/* Contact Info */}
<div className="space-y-6">
<div>
<h3 className="text-sm font-semibold uppercase text-gray-500">Email</h3>
<p className="mt-1 text-gray-900">office@clossers.com</p>
</div>
<div>
<h3 className="text-sm font-semibold uppercase text-gray-500">Telefon</h3>
<p className="mt-1 text-gray-900">+40 721 063 078</p>
</div>
<div>
<h3 className="text-sm font-semibold uppercase text-gray-500">Adresa</h3>
<p className="mt-1 text-gray-900">
Str. Targovistei 15, Bl. 2, Et. 3, Ap. 22<br />
Ploiesti, Prahova 100299<br />
Romania
</p>
</div>
</div>
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,21 @@
import Navbar from "@/components/layout/Navbar";
import Footer from "@/components/layout/Footer";
import PnrrBanner from "@/components/layout/PnrrBanner";
export default function PublicLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<a href="#main-content" className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[100] focus:rounded-lg focus:bg-teal-600 focus:px-4 focus:py-2 focus:text-white focus:outline-none">
Salt la continut
</a>
<PnrrBanner />
<Navbar />
<main id="main-content" className="flex-1" tabIndex={-1}>{children}</main>
<Footer />
</>
);
}

View file

@ -0,0 +1,219 @@
import Link from "next/link";
import { getWebsiteContent } from "@/lib/erpnext";
export default async function HomePage() {
let sections: Record<string, Record<string, string>> = {};
try {
const data = await getWebsiteContent("homepage");
for (const item of data) {
sections[item.section_key] = item;
}
} catch {}
const features = parseJson(sections.features?.extra_data) || [];
const steps = parseJson(sections.how_it_works?.extra_data) || [];
const stats = parseJson(sections.stats?.extra_data) || [];
const useCases = parseJson(sections.use_cases?.extra_data) || [];
const cta = parseJson(sections.hero_cta?.extra_data) || {};
const ctaFinal = parseJson(sections.cta_final?.extra_data) || {};
const aboutPlatform = parseJson(sections.about_platform?.extra_data) || {};
return (
<>
{/* Hero */}
<section className="relative flex min-h-[85vh] items-center justify-center overflow-hidden bg-white">
<div className="absolute inset-0 bg-gradient-to-b from-teal-50/60 via-white to-white" />
<div className="relative mx-auto max-w-3xl px-4 text-center">
<p className="text-sm font-medium tracking-widest text-teal-600 uppercase">
by Clossers
</p>
<h1 className="mt-6 text-5xl font-semibold tracking-tight text-gray-900 sm:text-6xl lg:text-7xl">
<span className="text-teal-600">didi</span>
</h1>
<p className="mt-4 text-xl font-light text-gray-500 sm:text-2xl">
Platforma AI pentru combaterea dezinformarii.
</p>
<p className="mx-auto mt-6 max-w-xl text-base text-gray-500">
{strip(sections.hero_subtitle?.content_ro) ||
"Analizeaza continut media si detecteaza dezinformarea in timp real."}
</p>
<div className="mt-10 flex items-center justify-center gap-4">
<Link
href={cta.link || "/pricing"}
className="rounded-full bg-teal-600 px-8 py-3 text-sm font-medium text-white transition hover:bg-teal-700"
>
{strip(sections.hero_cta?.content_ro) || "Incepe gratuit"}
</Link>
<Link
href={cta.secondary_link || "/services"}
className="rounded-full px-8 py-3 text-sm font-medium text-gray-600 transition hover:text-gray-900"
>
{cta.secondary_text_ro || "Afla mai mult"} &rarr;
</Link>
</div>
</div>
</section>
{/* About platform + Stats */}
{(aboutPlatform.desc_ro || stats.length > 0) && (
<section className="bg-white py-24">
<div className="mx-auto max-w-5xl px-4">
<div className="grid gap-16 md:grid-cols-2 md:items-center">
<div>
<p className="text-sm font-medium tracking-widest text-teal-600 uppercase">Despre platforma</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-gray-900 sm:text-4xl">
{strip(sections.about_platform?.content_ro) || "Tehnologie construita pentru adevar."}
</h2>
<p className="mt-5 text-base leading-relaxed text-gray-500">
{aboutPlatform.desc_ro || ""}
</p>
</div>
{stats.length > 0 && (
<div className="grid grid-cols-2 gap-4">
{stats.map((stat: Record<string, string>, i: number) => (
<div key={i} className="rounded-2xl bg-gray-50 p-6 text-center">
<div className="text-2xl font-semibold text-gray-900">{stat.value}</div>
<div className="mt-1 text-xs text-gray-500">{stat.label_ro}</div>
</div>
))}
</div>
)}
</div>
</div>
</section>
)}
{/* Features */}
{features.length > 0 && (
<section className="border-t border-gray-100 bg-gray-50/50 py-24">
<div className="mx-auto max-w-5xl px-4">
<p className="text-sm font-medium tracking-widest text-teal-600 uppercase">Capabilitati</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-gray-900">
Tot ce ai nevoie, intr-o singura platforma.
</h2>
<div className="mt-12 grid gap-4 sm:grid-cols-2">
{features.map((f: Record<string, string>, i: number) => (
<div key={i} className="rounded-2xl bg-white p-6 transition hover:shadow-sm">
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-xl bg-teal-50 text-teal-600">
<FeatureIcon name={f.icon} />
</div>
<h3 className="font-semibold text-gray-900">{f.title_ro}</h3>
<p className="mt-1.5 text-sm leading-relaxed text-gray-500">{f.desc_ro}</p>
</div>
))}
</div>
</div>
</section>
)}
{/* How it works */}
{steps.length > 0 && (
<section className="bg-white py-24">
<div className="mx-auto max-w-3xl px-4">
<div className="text-center">
<p className="text-sm font-medium tracking-widest text-teal-600 uppercase">Cum functioneaza</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-gray-900">
Simplu. Rapid. Precis.
</h2>
</div>
<div className="mt-14 space-y-0">
{steps.map((s: Record<string, string>, i: number) => (
<div key={i} className="flex gap-6 py-6">
<div className="flex flex-col items-center">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-teal-600 text-sm font-semibold text-white">
{s.step}
</div>
{i < steps.length - 1 && <div className="mt-2 h-full w-px bg-gray-200" />}
</div>
<div className="pb-2">
<h3 className="font-semibold text-gray-900">{s.title_ro}</h3>
<p className="mt-1 text-sm text-gray-500">{s.desc_ro}</p>
</div>
</div>
))}
</div>
</div>
</section>
)}
{/* Use cases */}
{useCases.length > 0 && (
<section className="border-t border-gray-100 bg-gray-50/50 py-24">
<div className="mx-auto max-w-5xl px-4">
<p className="text-sm font-medium tracking-widest text-teal-600 uppercase">Cazuri de utilizare</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-gray-900">
{strip(sections.use_cases?.content_ro) || "Creat pentru cei care protejeaza adevarul."}
</h2>
<div className="mt-12 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{useCases.map((uc: Record<string, string>, i: number) => (
<div key={i} className="rounded-2xl bg-white p-6">
<h3 className="font-semibold text-gray-900">{uc.title_ro}</h3>
<p className="mt-2 text-sm leading-relaxed text-gray-500">{uc.desc_ro}</p>
</div>
))}
</div>
</div>
</section>
)}
{/* CTA */}
<section className="bg-white py-24">
<div className="mx-auto max-w-2xl px-4 text-center">
<h2 className="text-3xl font-semibold tracking-tight text-gray-900">
{strip(sections.cta_final?.content_ro)?.replace(/<[^>]*>/g, "") || "Incepe sa verifici acum."}
</h2>
<p className="mt-4 text-base text-gray-500">
Cont gratuit. Fara card. Fara obligatii.
</p>
<div className="mt-8 flex items-center justify-center gap-4">
<Link
href={ctaFinal.primary_link || "/pricing"}
className="rounded-full bg-teal-600 px-8 py-3 text-sm font-medium text-white transition hover:bg-teal-700"
>
{ctaFinal.primary_text_ro || "Creeaza cont gratuit"}
</Link>
<Link
href={ctaFinal.secondary_link || "/contact"}
className="rounded-full px-8 py-3 text-sm font-medium text-gray-600 transition hover:text-gray-900"
>
{ctaFinal.secondary_text_ro || "Contacteaza-ne"} &rarr;
</Link>
</div>
</div>
</section>
</>
);
}
function strip(html?: string) {
if (!html) return "";
return html.replace(/<[^>]*>/g, "").trim();
}
function parseJson(str?: string) {
if (!str) return null;
try {
return JSON.parse(str);
} catch {
return null;
}
}
function FeatureIcon({ name }: { name: string }) {
const icons: Record<string, React.ReactNode> = {
search: (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
),
image: (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
),
check: (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
),
users: (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
),
};
return icons[name] || icons.check;
}

View file

@ -0,0 +1,367 @@
"use client";
import Link from "next/link";
import { useSession } from "next-auth/react";
import { useState } from "react";
// Costul in credite per analiza (din bos_sysadmin.subscription_plan):
// text/url = 1 credit, imagine = 2, audio = 3, video = 5
const subscriptionPlans = [
{
key: "free",
label: "Free",
tagline: "Pentru a testa platforma",
priceMonthly: 0,
priceYearly: 0,
features: [
"5 credite pe luna (5 analize text sau 1 video)",
"Toate tipurile de continut (text / URL / imagine / audio / video)",
"Acces dashboard cu istoric si export PDF",
"Stocare 1 GB",
"Suport email (zile lucratoare, 9-18)",
],
cta: "Inregistrare gratuita",
highlight: false,
},
{
key: "paid",
label: "Paid",
tagline: "Pentru utilizare frecventa",
priceMonthly: 99,
priceYearly: 999,
features: [
"250 credite pe luna (echivalent ~250 text / 125 imagini / 50 video)",
"Toate tipurile de continut",
"Acces dashboard + export PDF",
"Stocare 10 GB",
"Suport email (zile lucratoare, 9-18)",
],
cta: "Aboneaza-te",
highlight: true,
},
{
key: "enterprise",
label: "Enterprise",
tagline: "Pentru organizatii",
priceMonthly: 499,
priceYearly: 4990,
features: [
"6000 credite pe luna (echivalent ~6000 text / 3000 imagini / 1200 video)",
"Toate tipurile de continut",
"Acces API REST cu autentificare JWT",
"Stocare nelimitata",
"Suport email prioritar (raspuns in 8h ore lucratoare)",
],
cta: "Aboneaza-te",
highlight: false,
},
];
const oneTimeServices = [
{
component: "techniques",
label: "Techniques",
description: "Detectie tehnici de manipulare si propaganda din continut media",
prices: [
{ type: "text", label: "Text", ron: 50, eur: 10 },
{ type: "image", label: "Image", ron: 75, eur: 15 },
{ type: "audio", label: "Audio", ron: 100, eur: 20 },
{ type: "video", label: "Video", ron: 200, eur: 40 },
],
},
{
component: "ai_detection",
label: "AI Detection",
description: "Detectie deepfake si continut generat sau manipulat de AI",
prices: [
{ type: "text", label: "Text", ron: 50, eur: 10 },
{ type: "image", label: "Image", ron: 75, eur: 15 },
{ type: "audio", label: "Audio", ron: 100, eur: 20 },
{ type: "video", label: "Video", ron: 200, eur: 40 },
],
},
{
component: "claims",
label: "Claims",
description: "Fact-checking automat si verificare afirmatii cu surse credibile",
prices: [
{ type: "text", label: "Text", ron: 50, eur: 10 },
{ type: "image", label: "Image", ron: 75, eur: 15 },
{ type: "audio", label: "Audio", ron: 75, eur: 15 },
{ type: "video", label: "Video", ron: 150, eur: 30 },
],
},
{
component: "source",
label: "Source Assessment",
description: "Evaluare credibilitate surse, domenii web si publicatii",
prices: [
{ type: "text", label: "Text", ron: 50, eur: 10 },
{ type: "url", label: "URL", ron: 50, eur: 10 },
],
},
];
export default function PricingPage() {
const { data: session } = useSession();
const [loading, setLoading] = useState<string | null>(null);
const [billingCycle, setBillingCycle] = useState<"monthly" | "yearly">("monthly");
async function handleBuy(serviceKey: string) {
if (!session) {
window.location.href = "/login?callbackUrl=/pricing";
return;
}
setLoading(serviceKey);
try {
const res = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ serviceKey }),
});
const data = await res.json();
if (data.url) window.location.href = data.url;
} catch {
alert("Eroare la procesarea platii. Incearca din nou.");
} finally {
setLoading(null);
}
}
async function handleSubscribe(planKey: string) {
// Free tier = just go to register
if (planKey === "free") {
window.location.href = session ? "/dashboard" : "/register";
return;
}
if (!session) {
window.location.href = "/login?callbackUrl=/pricing";
return;
}
const stripePlanKey = `${planKey}-${billingCycle}`;
setLoading(stripePlanKey);
try {
const res = await fetch("/api/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ planKey: stripePlanKey }),
});
const data = await res.json();
if (data.url) window.location.href = data.url;
else alert(data.error || "Eroare la abonament");
} catch {
alert("Eroare la procesarea abonamentului. Incearca din nou.");
} finally {
setLoading(null);
}
}
return (
<>
{/* Hero */}
<section className="bg-gradient-to-b from-teal-50 to-white py-16">
<div className="mx-auto max-w-4xl px-4 text-center">
<h1 className="text-4xl font-bold text-gray-900">Tarife si abonamente</h1>
<p className="mt-4 text-lg text-gray-600">
Alege cum vrei sa platesti: abonament lunar/anual sau plata per analiza.
</p>
</div>
</section>
{/* SUBSCRIPTION PLANS */}
<section className="py-16">
<div className="mx-auto max-w-6xl px-4">
<div className="text-center">
<h2 className="text-3xl font-bold text-gray-900">Abonamente recurente</h2>
<p className="mt-3 text-gray-600">
Plata automata, fara grija de fiecare data. Anulezi oricand.
</p>
{/* Monthly/Yearly toggle */}
<div className="mt-6 inline-flex rounded-full border bg-white p-1 shadow-sm">
<button
onClick={() => setBillingCycle("monthly")}
className={`rounded-full px-5 py-2 text-sm font-medium transition ${
billingCycle === "monthly"
? "bg-teal-600 text-white"
: "text-gray-600 hover:text-gray-900"
}`}
>
Lunar
</button>
<button
onClick={() => setBillingCycle("yearly")}
className={`rounded-full px-5 py-2 text-sm font-medium transition ${
billingCycle === "yearly"
? "bg-teal-600 text-white"
: "text-gray-600 hover:text-gray-900"
}`}
>
Anual <span className="ml-1 text-xs opacity-75">(-15%)</span>
</button>
</div>
</div>
<div className="mt-10 grid gap-6 lg:grid-cols-3">
{subscriptionPlans.map((plan) => {
const price = billingCycle === "monthly" ? plan.priceMonthly : plan.priceYearly;
const period = billingCycle === "monthly" ? "/luna" : "/an";
const loadingKey = `${plan.key}-${billingCycle}`;
const isLoading = loading === loadingKey;
return (
<div
key={plan.key}
className={`relative flex flex-col rounded-2xl border bg-white p-8 ${
plan.highlight
? "border-teal-600 shadow-lg ring-2 ring-teal-600"
: "border-gray-200"
}`}
>
{plan.highlight && (
<span className="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-teal-600 px-3 py-1 text-xs font-semibold text-white">
Cel mai popular
</span>
)}
<h3 className="text-xl font-bold text-gray-900">{plan.label}</h3>
<p className="mt-1 text-sm text-gray-500">{plan.tagline}</p>
<div className="mt-5">
<span className="text-4xl font-bold text-gray-900">
{price === 0 ? "Gratuit" : `${price} RON`}
</span>
{price > 0 && <span className="ml-1 text-sm text-gray-500">{period}</span>}
</div>
<ul className="mt-6 space-y-3 flex-1">
{plan.features.map((f) => (
<li key={f} className="flex items-start gap-2 text-sm text-gray-600">
<svg className="mt-0.5 h-5 w-5 flex-shrink-0 text-teal-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span>{f}</span>
</li>
))}
</ul>
<button
onClick={() => handleSubscribe(plan.key)}
disabled={isLoading}
className={`mt-8 w-full rounded-lg py-3 text-sm font-semibold transition disabled:opacity-50 ${
plan.highlight
? "bg-teal-600 text-white hover:bg-teal-700"
: "border border-gray-300 bg-white text-gray-900 hover:bg-gray-50"
}`}
>
{isLoading ? "Se proceseaza..." : plan.cta}
</button>
</div>
);
})}
</div>
</div>
</section>
{/* DIVIDER */}
<div className="mx-auto my-8 max-w-5xl border-t border-gray-200" />
{/* ONE-TIME header */}
<section className="py-8">
<div className="mx-auto max-w-4xl px-4 text-center">
<h2 className="text-3xl font-bold text-gray-900">Plata per analiza</h2>
<p className="mt-3 text-gray-600">
Pentru utilizare ocazionala. Fara abonament, fara angajament. Alege serviciul si tipul de continut.
</p>
</div>
</section>
{/* Services */}
<section className="py-16">
<div className="mx-auto max-w-5xl px-4 space-y-8">
{oneTimeServices.map((svc) => (
<div key={svc.component} className="overflow-hidden rounded-2xl border border-gray-200 transition-shadow hover:shadow-lg">
<div className="border-b bg-gradient-to-r from-gray-50 to-white px-6 py-5">
<h2 className="text-xl font-bold text-gray-900">{svc.label}</h2>
<p className="mt-1 text-sm text-gray-500">{svc.description}</p>
</div>
<div className="grid grid-cols-2 gap-px bg-gray-100 sm:grid-cols-4">
{svc.prices.map((p) => {
const key = `${svc.component}-${p.type}`;
const isLoading = loading === key;
return (
<div key={p.type} className="flex flex-col items-center bg-white p-5">
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500">{p.label}</p>
<p className="mt-2 text-2xl font-bold text-gray-900">{p.ron}</p>
<p className="text-sm font-medium text-gray-500">RON</p>
<p className="text-xs text-gray-500">{p.eur} EUR</p>
<button
onClick={() => handleBuy(key)}
disabled={isLoading}
className="mt-3 w-full rounded-lg bg-teal-600 py-2 text-xs font-semibold text-white hover:bg-teal-700 disabled:opacity-50"
>
{isLoading ? "..." : "Achizitioneaza"}
</button>
</div>
);
})}
{svc.prices.length < 4 &&
Array.from({ length: 4 - svc.prices.length }).map((_, i) => (
<div key={`empty-${i}`} className="bg-gray-50 p-5" />
))}
</div>
</div>
))}
</div>
</section>
{/* How it works */}
<section className="bg-gray-50 py-16">
<div className="mx-auto max-w-4xl px-4">
<h2 className="text-center text-2xl font-bold text-gray-900">Cum functioneaza</h2>
<div className="mt-10 grid gap-px overflow-hidden rounded-2xl border bg-gray-200 sm:grid-cols-3">
{[
{ step: "1", title: "Alege serviciul", desc: "Selecteaza tipul de analiza si formatul continutului" },
{ step: "2", title: "Plateste securizat", desc: "Plata instant cu cardul prin Stripe. Factura generata automat." },
{ step: "3", title: "Primesti analiza", desc: "Rezultate in secunde: scor, dovezi, tehnici si recomandari." },
].map((s) => (
<div key={s.step} className="bg-white p-6 text-center">
<div className="mx-auto flex h-10 w-10 items-center justify-center rounded-full bg-teal-600 text-lg font-bold text-white">{s.step}</div>
<h3 className="mt-3 font-semibold text-gray-900">{s.title}</h3>
<p className="mt-2 text-sm text-gray-500">{s.desc}</p>
</div>
))}
</div>
</div>
</section>
{/* FAQ */}
<section className="py-16">
<div className="mx-auto max-w-3xl px-4">
<h2 className="text-center text-2xl font-bold text-gray-900">Intrebari frecvente</h2>
<div className="mt-8 space-y-4">
{[
{ q: "Ce primesc dupa achizitie?", a: "Un raport detaliat cu scorul de incredere, tehnicile identificate, surse de verificare si recomandari. Disponibil in dashboard si export PDF." },
{ q: "Cat dureaza o analiza?", a: "Text si URL: cateva secunde. Imagini si audio: sub 30s. Video: pana la cateva minute." },
{ q: "Ce metode de plata acceptati?", a: "Card (Visa, Mastercard) prin Stripe. Facturi fiscale generate automat." },
{ q: "Oferiti discount pentru volum?", a: "Da. Contacteaza-ne pentru oferte personalizate." },
].map((faq) => (
<div key={faq.q} className="rounded-xl border bg-white p-5">
<h3 className="font-semibold text-gray-900">{faq.q}</h3>
<p className="mt-2 text-sm text-gray-600">{faq.a}</p>
</div>
))}
</div>
</div>
</section>
{/* CTA */}
<section className="bg-teal-600 py-16">
<div className="mx-auto max-w-3xl px-4 text-center">
<h2 className="text-3xl font-bold text-white">Ai nevoie de volum mare?</h2>
<p className="mt-4 text-lg text-teal-100">Contacteaza-ne pentru pachete personalizate.</p>
<Link href="/contact" className="mt-8 inline-block rounded-xl bg-white px-8 py-3 text-base font-semibold text-teal-700 shadow-sm hover:bg-teal-50">
Contacteaza vanzari
</Link>
</div>
</section>
</>
);
}

View file

@ -0,0 +1,25 @@
import type { Metadata } from "next";
import { getWebsiteContent } from "@/lib/erpnext";
export const metadata: Metadata = {
title: "Politica de Confidentialitate",
};
export default async function PrivacyPage() {
let content = "";
try {
const data = await getWebsiteContent("privacy");
content = data[0]?.content_ro || "";
} catch {
content = "<h1>Politica de Confidentialitate</h1><p>Continutul va fi disponibil in curand.</p>";
}
return (
<section className="py-16">
<div
className="prose prose-teal mx-auto max-w-3xl px-4"
dangerouslySetInnerHTML={{ __html: content }}
/>
</section>
);
}

View file

@ -0,0 +1,138 @@
import Link from "next/link";
import type { Metadata } from "next";
import { getWebsiteContent } from "@/lib/erpnext";
export const metadata: Metadata = {
title: "Servicii DiDi - Analiza si Detectia Dezinformarii",
description: "Descopera modulele platformei DiDi: analiza text, detectie deepfake, fact-checking automat, evaluare surse si monitorizare media.",
};
interface Module {
id: string;
title_ro: string;
subtitle_ro: string;
desc_ro: string;
capabilities_ro: string[];
}
export default async function ServicesPage() {
let modules: Module[] = [];
try {
const data = await getWebsiteContent("services");
modules = data
.map((item) => {
try { return JSON.parse(item.extra_data); } catch { return null; }
})
.filter(Boolean);
} catch {}
// Fallback if ERPNext unavailable
if (modules.length === 0) {
modules = [
{ id: "text-analysis", title_ro: "Analiza Text & NLP", subtitle_ro: "Detectie dezinformare din text", desc_ro: "Procesare limbaj natural pentru identificarea manipularii.", capabilities_ro: ["Detectie propaganda", "Analiza sentiment", "Detectie clickbait"] },
{ id: "deepfake", title_ro: "Detectie Deepfake", subtitle_ro: "Imagini si video manipulate", desc_ro: "Retele neuronale pentru detectia manipularilor vizuale.", capabilities_ro: ["Imagini AI", "Deepfake video", "Metadata EXIF"] },
{ id: "fact-checking", title_ro: "Fact-Checking Automat", subtitle_ro: "Verificare afirmatii", desc_ro: "Comparare cu surse credibile si baze de date.", capabilities_ro: ["Extractie afirmatii", "Surse oficiale", "Scor credibilitate"] },
];
}
return (
<>
{/* Hero */}
<section className="bg-gradient-to-b from-teal-50 to-white py-20">
<div className="mx-auto max-w-4xl px-4 text-center">
<span className="inline-block rounded-full bg-teal-100 px-4 py-1.5 text-sm font-semibold text-teal-700">
Platforma DiDi
</span>
<h1 className="mt-6 text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl">
Inteligenta artificiala impotriva dezinformarii
</h1>
<p className="mt-6 text-lg text-gray-600">
DiDi combina modele AI/ML de ultima generatie cu expertiza umana (Human-in-the-Loop)
pentru a analiza, detecta si combate dezinformarea din continut media.
</p>
<div className="mt-8 flex flex-col items-center gap-4 sm:flex-row sm:justify-center">
<Link href="/pricing" className="rounded-xl bg-teal-600 px-8 py-3 text-base font-semibold text-white shadow-sm hover:bg-teal-700">
Incepe gratuit
</Link>
<Link href="/contact" className="rounded-xl border border-gray-300 px-8 py-3 text-base font-semibold text-gray-700 hover:bg-gray-50">
Solicita demo
</Link>
</div>
</div>
</section>
{/* How it works */}
<section className="py-16">
<div className="mx-auto max-w-5xl px-4">
<h2 className="text-center text-3xl font-bold text-gray-900">Cum functioneaza</h2>
<div className="mt-12 grid gap-px overflow-hidden rounded-2xl border bg-gray-200 sm:grid-cols-4">
{[
{ step: "1", title: "Incarca", desc: "Text, URL, imagine sau video" },
{ step: "2", title: "Analiza AI", desc: "6 module specializate in paralel" },
{ step: "3", title: "Validare", desc: "Experti Human-in-the-Loop" },
{ step: "4", title: "Raport", desc: "Scor + dovezi + recomandari" },
].map((s) => (
<div key={s.step} className="bg-white p-6 text-center">
<div className="mx-auto flex h-10 w-10 items-center justify-center rounded-full bg-teal-600 text-lg font-bold text-white">
{s.step}
</div>
<h3 className="mt-3 font-semibold text-gray-900">{s.title}</h3>
<p className="mt-1 text-sm text-gray-500">{s.desc}</p>
</div>
))}
</div>
</div>
</section>
{/* Modules */}
{modules.map((mod, i) => (
<section key={mod.id} id={mod.id} className={`py-16 ${i % 2 === 1 ? "bg-gray-50" : ""}`}>
<div className="mx-auto max-w-5xl px-4">
<div className="grid gap-12 lg:grid-cols-2 lg:items-center">
<div className={i % 2 === 1 ? "lg:order-2" : ""}>
<span className="inline-block rounded-full bg-teal-100 px-3 py-1 text-xs font-semibold text-teal-700">
Modul {i + 1}
</span>
<h2 className="mt-4 text-2xl font-bold text-gray-900">{mod.title_ro}</h2>
<p className="mt-2 text-lg text-gray-500">{mod.subtitle_ro}</p>
<p className="mt-4 text-gray-600">{mod.desc_ro}</p>
</div>
<div className={i % 2 === 1 ? "lg:order-1" : ""}>
<div className="rounded-xl border bg-white p-6">
<h3 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Capabilitati</h3>
<ul className="mt-4 space-y-3">
{mod.capabilities_ro.map((cap) => (
<li key={cap} className="flex items-start gap-3">
<svg className="mt-0.5 h-5 w-5 shrink-0 text-teal-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-sm text-gray-700">{cap}</span>
</li>
))}
</ul>
</div>
</div>
</div>
</div>
</section>
))}
{/* CTA */}
<section className="bg-teal-600 py-16">
<div className="mx-auto max-w-3xl px-4 text-center">
<h2 className="text-3xl font-bold text-white">Pregatit sa combati dezinformarea?</h2>
<p className="mt-4 text-lg text-teal-100">Incepe cu un cont gratuit. Fara card, fara obligatii.</p>
<div className="mt-8 flex flex-col items-center gap-4 sm:flex-row sm:justify-center">
<Link href="/register" className="rounded-xl bg-white px-8 py-3 text-base font-semibold text-teal-700 shadow-sm hover:bg-teal-50">
Creeaza cont gratuit
</Link>
<Link href="/contact" className="rounded-xl border border-teal-300 px-8 py-3 text-base font-semibold text-white hover:bg-teal-700">
Contacteaza vanzari
</Link>
</div>
</div>
</section>
</>
);
}

View file

@ -0,0 +1,25 @@
import type { Metadata } from "next";
import { getWebsiteContent } from "@/lib/erpnext";
export const metadata: Metadata = {
title: "Termeni si Conditii",
};
export default async function TermsPage() {
let content = "";
try {
const data = await getWebsiteContent("terms");
content = data[0]?.content_ro || "";
} catch {
content = "<h1>Termeni si Conditii</h1><p>Continutul va fi disponibil in curand.</p>";
}
return (
<section className="py-16">
<div
className="prose prose-teal mx-auto max-w-3xl px-4"
dangerouslySetInnerHTML={{ __html: content }}
/>
</section>
);
}

View file

@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { getCustomerByKeycloakId } from "@/lib/erpnext";
const ERPNEXT_URL = process.env.ERPNEXT_API_URL || "http://localhost:8080";
const API_KEY = process.env.ERPNEXT_API_KEY || "";
const API_SECRET = process.env.ERPNEXT_API_SECRET || "";
async function erpFetch<T>(endpoint: string): Promise<T> {
const res = await fetch(`${ERPNEXT_URL}${endpoint}`, {
headers: {
Authorization: `token ${API_KEY}:${API_SECRET}`,
},
});
if (!res.ok) {
throw new Error(`ERPNext API error: ${res.status} ${res.statusText}`);
}
return res.json();
}
export async function GET(request: NextRequest) {
const session = await auth();
const keycloakId = session?.user?.id;
if (!keycloakId) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const reportName = request.nextUrl.searchParams.get("name");
if (!reportName) {
return NextResponse.json({ error: "Missing report name" }, { status: 400 });
}
const customerName = await getCustomerByKeycloakId(keycloakId);
if (!customerName) {
return NextResponse.json({ error: "Customer not found" }, { status: 404 });
}
const report = await erpFetch<{ data: Record<string, unknown> }>(
`/api/resource/Analysis%20Report/${encodeURIComponent(reportName)}`,
);
if (String(report.data.customer || "") !== customerName) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const fileUrl = String(report.data.pdf_file || "");
if (!fileUrl) {
return NextResponse.json({ error: "PDF not found" }, { status: 404 });
}
const fileRes = await fetch(`${ERPNEXT_URL}${fileUrl}`, {
headers: {
Authorization: `token ${API_KEY}:${API_SECRET}`,
},
});
if (!fileRes.ok) {
return NextResponse.json({ error: "PDF download failed" }, { status: fileRes.status });
}
const pdf = await fileRes.arrayBuffer();
return new NextResponse(pdf, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${reportName}.pdf"`,
},
});
}

View file

@ -0,0 +1,91 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { getCustomerByKeycloakId } from "@/lib/erpnext";
import { persistAnalysisReport } from "@/lib/analysis-report-store";
const ERPNEXT_URL = process.env.ERPNEXT_API_URL || "http://localhost:8080";
const API_KEY = process.env.ERPNEXT_API_KEY || "";
const API_SECRET = process.env.ERPNEXT_API_SECRET || "";
async function erpFetch<T>(endpoint: string): Promise<T> {
const res = await fetch(`${ERPNEXT_URL}${endpoint}`, {
headers: {
Authorization: `token ${API_KEY}:${API_SECRET}`,
},
});
if (!res.ok) {
throw new Error(`ERPNext API error: ${res.status} ${res.statusText}`);
}
return res.json();
}
export async function GET() {
const session = await auth();
const keycloakId = session?.user?.id;
if (!keycloakId) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const customerName = await getCustomerByKeycloakId(keycloakId);
if (!customerName) {
return NextResponse.json({ data: [] });
}
const result = await erpFetch<{ data: Array<Record<string, unknown>> }>(
`/api/resource/Analysis%20Report?filters=[["customer","=","${encodeURIComponent(customerName)}"]]&fields=["name","sales_invoice","component","media_type","status","pdf_file","generated_at","creation"]&order_by=creation desc&limit_page_length=50`,
).catch(() => ({ data: [] }));
return NextResponse.json(result);
}
export async function POST(request: Request) {
const session = await auth();
const keycloakId = session?.user?.id;
if (!keycloakId) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const customerName = await getCustomerByKeycloakId(keycloakId);
if (!customerName) {
return NextResponse.json({ error: "Customer not found" }, { status: 404 });
}
const body = await request.json();
const invoiceName = String(body.invoiceName || "");
const sessionId = String(body.sessionId || "");
const component = String(body.component || "");
const media = String(body.media || "");
const locale = body.locale === "en" ? "en" : "ro";
const status = String(body.status || "Completed");
const result =
body.result && typeof body.result === "object"
? (body.result as Record<string, unknown>)
: null;
if (!invoiceName || !sessionId || !component || !result) {
return NextResponse.json({ error: "Missing report payload" }, { status: 400 });
}
try {
const saved = await persistAnalysisReport({
customerName,
invoiceName,
sessionId,
component,
media,
locale,
status,
result,
});
return NextResponse.json({ data: saved });
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Failed to persist analysis report",
},
{ status: 500 },
);
}
}

View file

@ -0,0 +1,400 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { getCustomerByKeycloakId, getCustomer } from "@/lib/erpnext";
import { persistAnalysisReport } from "@/lib/analysis-report-store";
import { creditsForMedia, getDidiCredits } from "@/lib/didi-backend";
/**
* Subscribers (active_plan paid/enterprise) run analyses on their monthly
* credits instead of per-analysis invoices. The DiDi backend deducts the
* credits itself; the site only checks the plan + balance up front.
* `invoice: "subscription"` is the sentinel used by the dashboard for these.
*/
const SUBSCRIPTION_INVOICE = "subscription";
const SUBSCRIPTION_PLANS = new Set(["paid", "enterprise"]);
const SUBSCRIPTION_ITEM_CODES = new Set(["DIDI-PAID", "DIDI-ENTERPRISE", "DIDI-FREE"]);
const SUBSCRIPTION_CATALOG: Array<{ component: string; media: string[] }> = [
{ component: "TECHNIQUES", media: ["TEXT", "IMAGE", "AUDIO", "VIDEO"] },
{ component: "AI_DETECTION", media: ["TEXT", "IMAGE", "AUDIO", "VIDEO"] },
{ component: "CLAIMS", media: ["TEXT", "IMAGE", "AUDIO", "VIDEO"] },
{ component: "SOURCE", media: ["TEXT", "URL"] },
];
async function getSubscriptionAccess(customerName: string, accessToken: string) {
const customer = await getCustomer(customerName).catch(() => null);
const plan = String(customer?.data?.active_plan || "").toLowerCase();
if (!SUBSCRIPTION_PLANS.has(plan)) return null;
const credits = await getDidiCredits(accessToken);
return { plan, credits: credits ?? 0 };
}
const DIDI_API = process.env.DIDI_API_URL || "http://didi-agent-v3:24803";
const ERPNEXT_URL = process.env.ERPNEXT_API_URL || "http://localhost:8080";
const API_KEY = process.env.ERPNEXT_API_KEY || "";
const API_SECRET = process.env.ERPNEXT_API_SECRET || "";
const ENDPOINTS: Record<string, string> = {
TECHNIQUES: "/v3/techniques/analyze",
AI_DETECTION: "/v3/ai-tampered/analyze",
CLAIMS: "/v3/claims/analyze",
SOURCE: "/v3/domain/analyze",
};
// Parse item_code like "DIDI-TECHNIQUES-TEXT" into { component: "TECHNIQUES", media: "TEXT" }
function parseItemCode(code: string) {
const parts = code.replace("DIDI-", "").split("-");
return { component: parts[0], media: parts[1] };
}
async function erpFetch<T = unknown>(endpoint: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${ERPNEXT_URL}${endpoint}`, {
...options,
headers: {
Authorization: `token ${API_KEY}:${API_SECRET}`,
"Content-Type": "application/json",
...options?.headers,
},
});
if (!res.ok) {
const body = await res.text();
throw new Error(`ERP ${options?.method || "GET"} ${endpoint} -> ${res.status}: ${body.slice(0, 300)}`);
}
return res.json();
}
function isInvoiceEligible(invoice: Record<string, unknown> | undefined) {
if (!invoice) return false;
const status = String(invoice.status || "");
const outstanding = Number(invoice.outstanding_amount || 0);
return status === "Paid" || outstanding === 0;
}
function hasFinalResult(data: unknown) {
if (!data || typeof data !== "object") return false;
const result = data as Record<string, unknown>;
return (
result.status === "completed" ||
typeof result.risk_score === "number" ||
!!result.result ||
!!result.verdict ||
!!result.techniques ||
!!result.claims ||
!!result.ai_tampered ||
!!result.domain ||
!!result.source_assessment
);
}
async function getInvoiceContextBySession(customerName: string, sessionId: string) {
const matches = await erpFetch<{ data: Array<{ name: string }> }>(
`/api/resource/Sales%20Invoice?filters=[["customer","=","${encodeURIComponent(customerName)}"],["analysis_session_id","=","${encodeURIComponent(sessionId)}"]]&fields=["name"]&limit_page_length=1`
).catch(() => ({ data: [] }));
const invoiceName = matches.data[0]?.name;
if (!invoiceName) return null;
const detail = await erpFetch<{ data: Record<string, unknown> }>(
`/api/resource/Sales%20Invoice/${encodeURIComponent(invoiceName)}`
).catch(() => null);
const items = (detail?.data?.items || []) as Array<Record<string, unknown>>;
const didiItem = items.find((item) => String(item.item_code || "").startsWith("DIDI-"));
const code = String(didiItem?.item_code || "");
const parsed = code ? parseItemCode(code) : { component: "", media: "" };
return {
invoiceName,
component: parsed.component,
media: parsed.media,
};
}
// GET — list available (paid, unconsumed) analyses for current user
export async function GET(request: NextRequest) {
const session = await auth();
const keycloakId = session?.user?.id;
if (!keycloakId) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const customerName = await getCustomerByKeycloakId(keycloakId);
if (!customerName) {
return NextResponse.json({ data: [] });
}
// Get submitted invoices (docstatus=1) that are not consumed
const invoices = await erpFetch<{ data: Array<Record<string, unknown>> }>(
`/api/resource/Sales%20Invoice?filters=[["customer","=","${encodeURIComponent(customerName)}"],["docstatus","=",1],["analysis_consumed","=",0]]&fields=["name","posting_date","grand_total","currency","status","outstanding_amount"]&order_by=posting_date desc&limit_page_length=50`
);
// For each invoice, get the items
const available = [];
for (const inv of invoices.data || []) {
const detail = await erpFetch<{ data: Record<string, unknown> }>(`/api/resource/Sales%20Invoice/${encodeURIComponent(String(inv.name))}`);
if (!isInvoiceEligible(detail?.data)) {
continue;
}
const items = (detail?.data?.items || []) as Array<Record<string, unknown>>;
for (const item of items) {
const code = String(item.item_code || "");
// subscription invoices (DIDI-PAID/ENTERPRISE) are not consumable analyses
if (code.startsWith("DIDI-") && !SUBSCRIPTION_ITEM_CODES.has(code)) {
const { component, media } = parseItemCode(code);
available.push({
invoice: inv.name,
item_code: code,
item_name: item.item_name,
component,
media,
posting_date: inv.posting_date,
amount: inv.grand_total,
currency: inv.currency,
});
}
}
}
// Also get session_id param for polling
const sessionId = request.nextUrl.searchParams.get("session_id");
if (sessionId) {
const invoiceName = request.nextUrl.searchParams.get("invoice");
const component = request.nextUrl.searchParams.get("component");
const media = request.nextUrl.searchParams.get("media");
const accessToken = (session as unknown as Record<string, unknown>)?.accessToken as string;
// First check queue-status to see if still processing
const queueRes = await fetch(`${DIDI_API}/v3/pipeline/${sessionId}/queue-status`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const queueData = await queueRes.json();
const queueStatus = queueData?.data?.status;
// Check if queue progress indicates completion even if status is stuck on "running"
const queueProgress = Number(queueData?.data?._queue?.progress || 0);
const isQueueDone = queueProgress >= 100;
// If still truly running (not done), return queue-status so frontend keeps polling
if (!isQueueDone && (queueStatus === "running" || queueStatus === "pending" || queueStatus === "processing")) {
return NextResponse.json({ data: { status: queueStatus, ...(queueData?.data?._queue || {}) } });
}
// Completed or truly failed — fetch full result
const res = await fetch(`${DIDI_API}/v3/pipeline/${sessionId}/result`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const data = await res.json();
const resultData = data?.data || data;
if (hasFinalResult(resultData)) {
const context =
invoiceName
? {
invoiceName,
component: component || "",
media: media || "",
}
: await getInvoiceContextBySession(customerName, sessionId);
if (context) {
await persistAnalysisReport({
customerName,
invoiceName: context.invoiceName,
sessionId,
component: context.component,
media: context.media,
locale: request.nextUrl.searchParams.get("language") === "en" ? "en" : "ro",
status: "Completed",
result: resultData as Record<string, unknown>,
}).catch(() => null);
}
}
return NextResponse.json(data, { status: res.status });
}
// Subscribers: one virtual entry per service, paid from the monthly credits
const accessTokenForCredits = (session as unknown as Record<string, unknown>)?.accessToken as string;
const subscription = accessTokenForCredits
? await getSubscriptionAccess(customerName, accessTokenForCredits)
: null;
if (subscription) {
for (const entry of SUBSCRIPTION_CATALOG) {
for (const media of entry.media) {
available.push({
invoice: SUBSCRIPTION_INVOICE,
item_code: `SUBSCRIPTION-${entry.component}-${media}`,
item_name: `${entry.component} - ${media} (abonament ${subscription.plan})`,
component: entry.component,
media,
posting_date: "",
amount: creditsForMedia(media),
currency: "credite",
plan: subscription.plan,
credits_available: subscription.credits,
});
}
}
}
return NextResponse.json({ data: available, subscription });
}
const FILE_MEDIA_TYPES = new Set(["IMAGE", "AUDIO", "VIDEO"]);
async function uploadMediaToDidi(file: File, accessToken: string) {
const formData = new FormData();
formData.append("file", file);
const res = await fetch(`${DIDI_API}/v3/media/upload`, {
method: "POST",
headers: { Authorization: `Bearer ${accessToken}` },
body: formData,
});
if (!res.ok) {
throw new Error(`Media upload failed: ${res.status}`);
}
const json = await res.json();
return json?.data?.public_url as string;
}
// POST — run analysis, consume the invoice
export async function POST(request: NextRequest) {
const session = await auth();
const accessToken = (session as unknown as Record<string, unknown>)?.accessToken as string;
const keycloakId = session?.user?.id;
if (!accessToken) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
// Parse body — JSON for text/url, FormData for file uploads
let invoice = "";
let component = "";
let media = "";
let text = "";
let inputUrl = "";
let language = "";
let file: File | null = null;
const contentType = request.headers.get("content-type") || "";
if (contentType.includes("multipart/form-data")) {
const formData = await request.formData();
invoice = String(formData.get("invoice") || "");
component = String(formData.get("component") || "");
media = String(formData.get("media") || "");
language = String(formData.get("language") || "");
text = String(formData.get("text") || "");
inputUrl = String(formData.get("url") || "");
const f = formData.get("file");
if (f instanceof File) file = f;
} else {
const body = await request.json();
invoice = body.invoice || "";
component = body.component || "";
media = body.media || "";
text = body.text || "";
inputUrl = body.url || "";
language = body.language || "";
}
if (!invoice || !component) {
return NextResponse.json({ error: "Missing invoice or component" }, { status: 400 });
}
const endpoint = ENDPOINTS[component];
if (!endpoint) {
return NextResponse.json({ error: "Invalid component" }, { status: 400 });
}
const customerName = keycloakId ? await getCustomerByKeycloakId(keycloakId) : null;
if (!customerName) {
return NextResponse.json({ error: "Customer not found" }, { status: 404 });
}
const isSubscriptionRun = invoice === SUBSCRIPTION_INVOICE;
if (isSubscriptionRun) {
// Credit-based run: needs an active paid/enterprise plan and enough credits
const subscription = await getSubscriptionAccess(customerName, accessToken);
if (!subscription) {
return NextResponse.json({ error: "Nu ai un abonament activ. Achizitioneaza o analiza sau un abonament." }, { status: 403 });
}
const cost = creditsForMedia(media);
if (subscription.credits < cost) {
return NextResponse.json(
{ error: `Credite insuficiente: ai ${subscription.credits}, analiza costa ${cost}.` },
{ status: 402 },
);
}
} else {
const invoiceDetail = await erpFetch<{ data: Record<string, unknown> }>(`/api/resource/Sales%20Invoice/${encodeURIComponent(invoice)}`).catch(() => null);
const invoiceData = invoiceDetail?.data;
if (!invoiceData || String(invoiceData.customer || "") !== customerName) {
return NextResponse.json({ error: "Invoice not available for this user" }, { status: 403 });
}
if (Number(invoiceData.docstatus || 0) !== 1 || Number(invoiceData.analysis_consumed || 0) === 1) {
return NextResponse.json({ error: "Invoice is not eligible for analysis" }, { status: 409 });
}
if (!isInvoiceEligible(invoiceData)) {
return NextResponse.json({ error: "Invoice must be paid before analysis can start" }, { status: 409 });
}
}
// Build payload
const payload: Record<string, string> = {};
if (FILE_MEDIA_TYPES.has(media) && file) {
// Upload file to DiDi MinIO, then send media_url
const mediaUrl = await uploadMediaToDidi(file, accessToken);
payload.media_type = media.toLowerCase();
payload.media_url = mediaUrl;
} else if (text) {
payload.text = text;
} else if (inputUrl) {
payload.url = inputUrl;
}
if (language === "ro" || language === "en") payload.language = language;
// Send to DiDi API
const res = await fetch(`${DIDI_API}${endpoint}`, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const data = await res.json();
const sessionId = data?.data?.session_id || data?.session_id;
if (sessionId && !isSubscriptionRun) {
// Mark invoice as consumed; analysis already ran, so surface ERP failures without losing the result
try {
await erpFetch(`/api/resource/Sales%20Invoice/${encodeURIComponent(invoice)}`, {
method: "PUT",
body: JSON.stringify({
analysis_consumed: 1,
analysis_session_id: sessionId,
}),
});
} catch (err) {
console.error("[analyze] failed to mark invoice consumed:", invoice, err);
}
}
if (hasFinalResult(data?.data || data)) {
await persistAnalysisReport({
customerName,
invoiceName: invoice,
sessionId: sessionId || `direct-${Date.now()}`,
component,
media: media || "",
locale: language === "en" ? "en" : "ro",
status: "Completed",
result: (data?.data || data) as Record<string, unknown>,
}).catch(() => null);
}
return NextResponse.json(data, { status: res.status });
}

View file

@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;

View file

@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { stripe, PRICE_MAP, getOrCreateStripeCustomer } from "@/lib/stripe";
export async function POST(request: NextRequest) {
// Require authentication
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const { serviceKey } = await request.json();
const service = PRICE_MAP[serviceKey];
if (!service) {
return NextResponse.json({ error: "Invalid service" }, { status: 400 });
}
// One Stripe Customer per user (keyed by keycloak_id) — never customer_email,
// which would create a new Stripe Customer on every checkout
const stripeCustomerId = await getOrCreateStripeCustomer({
keycloakId: session.user.id,
email: session.user.email || "",
name: session.user.name || "",
});
const checkoutSession = await stripe.checkout.sessions.create({
mode: "payment",
payment_method_types: ["card"],
// The Stripe account has Managed Payments (Stripe = merchant of record) on by
// default; disable it per session: invoicing is done by TOP CLOSSERS through
// the ERP, and Managed Payments rejects payment_method_types anyway.
...({ managed_payments: { enabled: false } } as Record<string, unknown>),
customer: stripeCustomerId,
line_items: [
{
price: service.priceId,
quantity: 1,
},
],
metadata: {
user_email: session.user.email || "",
user_name: session.user.name || "",
keycloak_id: session.user.id || "",
component: service.component,
media_type: service.mediaType,
service_label: service.label,
},
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/dashboard/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing`,
});
return NextResponse.json({ url: checkoutSession.url });
}

View file

@ -0,0 +1,64 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
const FRAMEWORK_URL = process.env.DIDI_FRAMEWORK_URL || "http://didi-framework:3005";
const DIDI_API = process.env.DIDI_API_URL || "http://didi-agent-v3:24803/api";
export async function GET() {
const session = await auth();
const accessToken = (session as unknown as Record<string, unknown>)?.accessToken as string;
const userId = session?.user?.id;
if (!accessToken || !userId) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
try {
// 1. Credits summary from didiFramework
const creditsRes = await fetch(`${FRAMEWORK_URL}/api/auth/credits`, {
headers: { Authorization: `Bearer ${accessToken}` },
cache: "no-store",
});
if (!creditsRes.ok) {
const errText = await creditsRes.text();
console.error("[Credits] Framework error:", creditsRes.status, errText.substring(0, 200));
return NextResponse.json({ error: "Failed to fetch credits" }, { status: creditsRes.status });
}
const credits = await creditsRes.json();
// 2. Usage history from agent-v3 (recent analyses)
const histRes = await fetch(`${DIDI_API}/v3/pipeline/history?user_id=${encodeURIComponent(userId)}&limit=20`, {
headers: { Authorization: `Bearer ${accessToken}` },
cache: "no-store",
});
type HistoryItem = {
session_id?: string;
created_at?: string;
started_at?: string;
input_type?: string;
components_run?: string[];
risk_score?: number;
};
let usage: Array<{ date: string; type: string; credits: number }> = [];
if (histRes.ok) {
const histData = await histRes.json();
const items = histData?.data?.items || [];
const mediaCost: Record<string, number> = { text: 1, url: 1, image: 2, audio: 3, video: 5 };
usage = items.map((item: HistoryItem) => ({
date: (item.created_at || item.started_at || "").substring(0, 10),
type: `Analiza ${item.input_type || "necunoscut"} (${(item.components_run || []).join(", ")})`,
credits: mediaCost[item.input_type || "text"] || 1,
}));
}
return NextResponse.json({
credits: credits.data,
usage,
});
} catch (err) {
console.error("[Credits] Error:", err);
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}

View file

@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { getCustomerByKeycloakId, getCustomer } from "@/lib/erpnext";
export const dynamic = "force-dynamic";
export async function GET() {
const session = await auth();
const keycloakId = session?.user?.id;
if (!keycloakId) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const customerName = await getCustomerByKeycloakId(keycloakId);
if (!customerName) {
return NextResponse.json({ error: "Customer not found" }, { status: 404 });
}
const customer = await getCustomer(customerName);
return NextResponse.json(customer);
}

View file

@ -0,0 +1,147 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { getCustomerByKeycloakId } from "@/lib/erpnext";
const ERPNEXT_URL = process.env.ERPNEXT_API_URL || "http://localhost:8080";
const API_KEY = process.env.ERPNEXT_API_KEY || "";
const API_SECRET = process.env.ERPNEXT_API_SECRET || "";
/**
* Server-side proxy to ERPNext API.
* All client-side dashboard calls go through /api/erp/... instead of hitting ERPNext directly.
* This avoids CORS issues and keeps API credentials server-side.
*
* SECURITY: requires an authenticated session AND restricts which ERPNext
* doctypes/resources can be reached the proxy carries admin credentials, so
* it must never be an open relay. Reads are limited to CMS/catalog data;
* writes are limited to lead capture. Everything else (Customer, Sales Invoice,
* Payment Log, ...) is handled by dedicated, ownership-checked routes.
*/
// Resources the authenticated dashboard legitimately reads (see lib/api.ts).
// NOTE: filtering by customer is still done client-side — a hardened version
// should inject the session's own customer server-side to prevent IDOR. This
// allowlist at least stops the proxy from reaching unrelated doctypes
// (User, Role, API keys, ...) and requires a valid session.
const READ_ALLOW = [
"resource/Website Content",
"resource/Item",
"resource/Subscription Plan",
"resource/Subscription",
"resource/Sales Invoice",
"resource/Customer",
"method/frappe.client.get_list",
"method/frappe.client.get_value",
];
// Write endpoints allowed through the generic proxy.
const WRITE_ALLOW = ["resource/Lead", "resource/Customer"];
function isAllowed(erpPath: string, allow: string[]): boolean {
const decoded = decodeURIComponent(erpPath).replace(/^\/api\//, "");
return allow.some((a) => decoded === a || decoded.startsWith(a + "/") || decoded.startsWith(a + "?"));
}
// Doctypes whose rows belong to a specific customer. Any request touching one
// of these must reference ONLY the caller's own customer — otherwise it's an
// IDOR (reading/altering another user's data). Website Content / Item /
// Subscription Plan are shared catalog data and are not scoped.
const CUSTOMER_SCOPED = ["Customer", "Sales Invoice", "Subscription"];
/**
* Enforce that a request to a customer-scoped resource only references the
* caller's own ERPNext customer. Returns null if OK, or an error response.
*/
function enforceOwnership(
erpPath: string,
search: string,
ownCustomer: string | null
): NextResponse | null {
const decoded = decodeURIComponent(erpPath).replace(/^\/api\//, "");
const scoped = CUSTOMER_SCOPED.find(
(dt) => decoded === `resource/${dt}` || decoded.startsWith(`resource/${dt}/`) || decoded.startsWith(`resource/${dt}?`)
);
if (!scoped) return null; // shared/catalog resource — no scoping needed
if (!ownCustomer) {
return NextResponse.json({ error: "No customer profile for this account" }, { status: 403 });
}
// Direct access by name: /resource/Customer/<NAME> or /resource/Sales Invoice/<NAME>
const nameMatch = decoded.match(new RegExp(`^resource/${scoped}/(.+)$`));
if (nameMatch) {
const name = decodeURIComponent(nameMatch[1]);
// Sales Invoice / Subscription names aren't the customer — allow by name only
// for Customer (which IS the customer). Invoice/Subscription by-name reads
// are covered by the filter check below; deny bare Customer/<other>.
if (scoped === "Customer" && name !== ownCustomer) {
return NextResponse.json({ error: "Forbidden: not your resource" }, { status: 403 });
}
return null;
}
// Filtered list: the customer/party filter value must be the caller's own.
const q = decodeURIComponent(search);
const referenced = [...q.matchAll(/"(?:customer|party)"\s*,\s*"="\s*,\s*"([^"]+)"/g)].map((m) => m[1]);
if (referenced.some((c) => c !== ownCustomer)) {
return NextResponse.json({ error: "Forbidden: not your resource" }, { status: 403 });
}
// A list with no customer filter on a scoped doctype would leak all rows — deny.
if (referenced.length === 0) {
return NextResponse.json({ error: "Forbidden: customer filter required" }, { status: 403 });
}
return null;
}
async function proxy(request: NextRequest, path: string[], method: "GET" | "POST" | "PUT") {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const erpPath = `/api/${path.join("/")}`;
const allow = method === "GET" ? READ_ALLOW : WRITE_ALLOW;
if (!isAllowed(erpPath, allow)) {
return NextResponse.json(
{ error: "Forbidden: resource not permitted via generic proxy" },
{ status: 403 }
);
}
const search = request.nextUrl.searchParams.toString();
// Prevent IDOR: scope customer-owned resources to the caller's own customer.
const ownCustomer = session.user.id
? await getCustomerByKeycloakId(session.user.id)
: null;
const ownershipError = enforceOwnership(erpPath, search, ownCustomer);
if (ownershipError) return ownershipError;
const url = `${ERPNEXT_URL}${erpPath}${method === "GET" && search ? `?${search}` : ""}`;
try {
const res = await fetch(url, {
method,
headers: {
Authorization: `token ${API_KEY}:${API_SECRET}`,
...(method !== "GET" ? { "Content-Type": "application/json" } : {}),
},
...(method !== "GET" ? { body: await request.text() } : {}),
});
const data = await res.json();
return NextResponse.json(data, { status: res.status });
} catch {
return NextResponse.json({ error: "ERPNext API unavailable" }, { status: 502 });
}
}
export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
return proxy(request, (await params).path, "GET");
}
export async function POST(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
return proxy(request, (await params).path, "POST");
}
export async function PUT(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
return proxy(request, (await params).path, "PUT");
}

View file

@ -0,0 +1,163 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { getCustomerByKeycloakId } from "@/lib/erpnext";
const ERPNEXT_URL = process.env.ERPNEXT_API_URL || "http://didi-erpnext:8080";
const API_KEY = process.env.ERPNEXT_API_KEY || "";
const API_SECRET = process.env.ERPNEXT_API_SECRET || "";
const FRAMEWORK_URL = process.env.DIDI_FRAMEWORK_URL || "http://didi-framework:3005";
const erpAuth = `token ${API_KEY}:${API_SECRET}`;
async function erpFetch<T>(endpoint: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${ERPNEXT_URL}${endpoint}`, {
...options,
headers: {
Authorization: erpAuth,
"Content-Type": "application/json",
...options?.headers,
},
});
if (!res.ok) {
const txt = await res.text();
throw new Error(`ERP ${res.status}: ${txt.substring(0, 200)}`);
}
return res.json();
}
/**
* GDPR Art. 17 Right to erasure ("Right to be forgotten")
*
* Body: { confirm: true, reason?: string }
*
* What gets deleted:
* - Analysis Report details (result_json, pdf_file) internal user content
* - Customer PII (customer_name, email, didi_user_id, address) anonymized
* - User in didiFramework PG (bos_sysadmin.user_credential.email/keycloak_id) anonymized
*
* What gets PRESERVED (legal retention 10 years per OMFP 2634/2015):
* - Sales Invoices (anonymized customer name)
* - Payment Entries
* - GL Entries
* - Service Agreements (kept as fiscal contracts)
*
* NOTE: User must re-confirm via email before deletion happens. This route
* MARKS the account as pending_deletion. A scheduled job (or admin action)
* does the actual anonymization after grace period.
*/
export async function POST(request: NextRequest) {
const session = await auth();
const keycloakId = session?.user?.id;
const email = session?.user?.email || "";
if (!keycloakId) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const body = await request.json().catch(() => ({}));
const confirm = body.confirm === true;
const reason = String(body.reason || "Solicitare utilizator GDPR Art. 17").substring(0, 500);
const executeNow = body.execute === true; // For testing/admin override
if (!confirm) {
return NextResponse.json({
error: "Required: { confirm: true }. Aceasta actiune este ireversibila pentru analize si date personale."
}, { status: 400 });
}
const customerId = await getCustomerByKeycloakId(keycloakId);
if (!executeNow) {
// STAGE 1: Mark as pending deletion (don't actually delete yet)
if (customerId) {
try {
await erpFetch(`/api/resource/Customer/${encodeURIComponent(customerId)}`, {
method: "PUT",
body: JSON.stringify({
disabled: 1,
customer_details: `[PENDING DELETION - ${new Date().toISOString().slice(0, 10)}] ${reason}`,
}),
});
} catch (e) {
console.error("[GDPR Delete] Mark pending failed:", e);
}
}
return NextResponse.json({
success: true,
status: "pending_deletion",
message: "Cererea de stergere a fost inregistrata. Contul va fi anonimizat in 30 zile, conform politicii. Pentru anulare contacteaza office@clossers.com.",
anonymization_date: new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10),
deleted_immediately: false,
kept_for_legal_retention: ["facturi", "plati", "acord servicii"],
});
}
// STAGE 2: Actual anonymization (admin override or after 30 days)
const anonId = `anon-${keycloakId.substring(0, 8)}-${Date.now()}`;
const anonymizedSummary: Record<string, unknown> = { reports_anonymized: 0, customer_anonymized: false };
// 2.1. Anonymize Analysis Reports (delete result_json and pdf_file)
if (customerId) {
try {
const reportsRes = await erpFetch<{ data: Array<{ name: string }> }>(
`/api/resource/Analysis%20Report?filters=${encodeURIComponent(JSON.stringify([["customer", "=", customerId]]))}&fields=${encodeURIComponent('["name"]')}&limit_page_length=500`
);
for (const r of reportsRes.data) {
await erpFetch(`/api/resource/Analysis%20Report/${encodeURIComponent(r.name)}`, {
method: "PUT",
body: JSON.stringify({
result_json: "[ANONIMIZAT GDPR Art. 17]",
pdf_file: null,
report_title: "[ANONIMIZAT]",
}),
});
anonymizedSummary.reports_anonymized = (anonymizedSummary.reports_anonymized as number) + 1;
}
} catch (e) {
console.error("[GDPR Delete] Reports anonymization failed:", e);
}
}
// 2.2. Anonymize Customer PII (keep entity for invoice references)
if (customerId) {
try {
await erpFetch(`/api/resource/Customer/${encodeURIComponent(customerId)}`, {
method: "PUT",
body: JSON.stringify({
customer_name: `Client anonimizat ${anonId}`,
email_id: `${anonId}@anonymized.didi.local`,
didi_user_id: anonId,
iam_role: "free_tier",
active_plan: "",
disabled: 1,
customer_details: `[ANONIMIZAT GDPR Art. 17 la ${new Date().toISOString()}] ${reason}`,
}),
});
anonymizedSummary.customer_anonymized = true;
} catch (e) {
console.error("[GDPR Delete] Customer anonymization failed:", e);
}
}
// 2.3. Anonymize user in didiFramework PG (user_credential)
try {
await fetch(`${FRAMEWORK_URL}/api/admin/users/anonymize`, {
method: "POST",
headers: {
Authorization: `Bearer ${(session as unknown as Record<string, unknown>).accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ keycloak_id: keycloakId, anon_id: anonId }),
}).catch(() => null);
} catch { /* framework may not have this endpoint; will need to be added separately */ }
return NextResponse.json({
success: true,
status: "anonymized",
message: "Datele personale au fost anonimizate. Facturile fiscale sunt pastrate 10 ani conform legii contabile RO.",
deleted_immediately: true,
anonymized: anonymizedSummary,
anon_id: anonId,
});
}

View file

@ -0,0 +1,163 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { getCustomerByKeycloakId, getCustomer, getInvoices } from "@/lib/erpnext";
const FRAMEWORK_URL = process.env.DIDI_FRAMEWORK_URL || "http://didi-framework:3005";
const DIDI_API = process.env.DIDI_API_URL || "http://didi-agent-v3:24803/api";
const ERPNEXT_URL = process.env.ERPNEXT_API_URL || "http://didi-erpnext:8080";
const API_KEY = process.env.ERPNEXT_API_KEY || "";
const API_SECRET = process.env.ERPNEXT_API_SECRET || "";
/**
* GDPR Art. 20 Right to data portability
* Exports all personal data the user has in DiDi platform as a JSON file.
*
* Scope:
* - Profile (Customer in ERPNext)
* - Invoices + Payment Entries + Payment Logs (financial history)
* - Subscription / Service Agreements
* - Analysis sessions + reports
* - Credit usage history
*
* Excludes: encryption keys, internal IDs only used by other systems.
*/
export async function GET() {
const session = await auth();
const accessToken = (session as unknown as Record<string, unknown>)?.accessToken as string;
const keycloakId = session?.user?.id;
const email = session?.user?.email || "";
if (!accessToken || !keycloakId) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const erpAuth = `token ${API_KEY}:${API_SECRET}`;
// ─── 1. Profile (ERPNext Customer) ───
const customerId = await getCustomerByKeycloakId(keycloakId);
let customer: Record<string, unknown> | null = null;
if (customerId) {
try {
const c = await getCustomer(customerId);
customer = c.data;
} catch { /* ignore */ }
}
// ─── 2. Invoices ───
let invoices: Array<Record<string, unknown>> = [];
if (customerId) {
try {
const inv = await getInvoices(customerId);
invoices = inv.data;
} catch { /* ignore */ }
}
// ─── 3. Payment Logs (by email, since Logs may not link to customer) ───
let paymentLogs: Array<Record<string, unknown>> = [];
try {
const plRes = await fetch(
`${ERPNEXT_URL}/api/resource/Payment%20Log?filters=${encodeURIComponent(JSON.stringify([["customer", "=", customerId || ""]]))}&fields=${encodeURIComponent('["name","event_type","status","amount","currency","stripe_session_id","stripe_payment_intent_id","creation"]')}&limit_page_length=200`,
{ headers: { Authorization: erpAuth }, cache: "no-store" }
);
if (plRes.ok) {
const pl = await plRes.json();
paymentLogs = pl.data || [];
}
} catch { /* ignore */ }
// ─── 4. Service Agreements ───
let agreements: Array<Record<string, unknown>> = [];
if (customerId) {
try {
const saRes = await fetch(
`${ERPNEXT_URL}/api/resource/Service%20Agreement?filters=${encodeURIComponent(JSON.stringify([["customer", "=", customerId]]))}&fields=${encodeURIComponent('["name","plan","status","acceptance_date","terms_version"]')}&limit_page_length=100`,
{ headers: { Authorization: erpAuth }, cache: "no-store" }
);
if (saRes.ok) {
const sa = await saRes.json();
agreements = sa.data || [];
}
} catch { /* ignore */ }
}
// ─── 5. Analysis Reports ───
let reports: Array<Record<string, unknown>> = [];
if (customerId) {
try {
const arRes = await fetch(
`${ERPNEXT_URL}/api/resource/Analysis%20Report?filters=${encodeURIComponent(JSON.stringify([["customer", "=", customerId]]))}&fields=${encodeURIComponent('["name","sales_invoice","analysis_session_id","component","media_type","status","generated_at"]')}&limit_page_length=200`,
{ headers: { Authorization: erpAuth }, cache: "no-store" }
);
if (arRes.ok) {
const ar = await arRes.json();
reports = ar.data || [];
}
} catch { /* ignore */ }
}
// ─── 6. Analysis sessions (from agent-v3) ───
let analyses: Array<Record<string, unknown>> = [];
try {
const hRes = await fetch(`${DIDI_API}/v3/pipeline/history?user_id=${encodeURIComponent(keycloakId)}&limit=200`, {
headers: { Authorization: `Bearer ${accessToken}` },
cache: "no-store",
});
if (hRes.ok) {
const h = await hRes.json();
analyses = h?.data?.items || [];
}
} catch { /* ignore */ }
// ─── 7. Credits info (from didiFramework) ───
let credits: Record<string, unknown> | null = null;
try {
const cRes = await fetch(`${FRAMEWORK_URL}/api/auth/credits`, {
headers: { Authorization: `Bearer ${accessToken}` },
cache: "no-store",
});
if (cRes.ok) {
const c = await cRes.json();
credits = c.data || null;
}
} catch { /* ignore */ }
// ─── Build export bundle ───
const exportData = {
export_metadata: {
generated_at: new Date().toISOString(),
keycloak_id: keycloakId,
email,
gdpr_article: "Art. 20 - Dreptul la portabilitatea datelor",
legal_basis: "Regulamentul (UE) 2016/679 (GDPR)",
data_controller: "TOP CLOSSERS SRL, CUI 36193026",
retention_note: "Datele fiscale (facturi, plati) sunt pastrate 10 ani conform legislatiei fiscale RO (OMFP 2634/2015).",
},
profile: {
keycloak_id: keycloakId,
email,
customer_id: customerId,
customer_data: customer,
credits,
},
financial_records: {
invoices,
payment_logs: paymentLogs,
service_agreements: agreements,
},
analyses: {
reports,
sessions: analyses,
},
};
// Return as downloadable JSON file
const json = JSON.stringify(exportData, null, 2);
const filename = `didi-export-${keycloakId.substring(0, 8)}-${new Date().toISOString().slice(0, 10)}.json`;
return new NextResponse(json, {
headers: {
"Content-Type": "application/json",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
}

View file

@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { getCustomerByKeycloakId } from "@/lib/erpnext";
const ERPNEXT_URL = process.env.ERPNEXT_API_URL || "http://localhost:8080";
const API_KEY = process.env.ERPNEXT_API_KEY || "";
const API_SECRET = process.env.ERPNEXT_API_SECRET || "";
export async function GET(request: NextRequest) {
const session = await auth();
const keycloakId = session?.user?.id;
if (!keycloakId) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const invoiceName = request.nextUrl.searchParams.get("name");
if (!invoiceName) {
return NextResponse.json({ error: "Missing invoice name" }, { status: 400 });
}
// Verify invoice belongs to current user
const customerName = await getCustomerByKeycloakId(keycloakId);
if (!customerName) {
return NextResponse.json({ error: "Customer not found" }, { status: 404 });
}
const invoiceRes = await fetch(
`${ERPNEXT_URL}/api/resource/Sales%20Invoice/${encodeURIComponent(invoiceName)}?fields=["customer"]`,
{ headers: { Authorization: `token ${API_KEY}:${API_SECRET}` } }
);
if (!invoiceRes.ok) {
return NextResponse.json({ error: "Invoice not found" }, { status: 404 });
}
const invoiceData = await invoiceRes.json();
if (invoiceData?.data?.customer !== customerName) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// Generate PDF
const res = await fetch(
`${ERPNEXT_URL}/api/method/frappe.utils.print_format.download_pdf?doctype=Sales%20Invoice&name=${encodeURIComponent(invoiceName)}&format=DiDi%20Invoice`,
{ headers: { Authorization: `token ${API_KEY}:${API_SECRET}` } }
);
if (!res.ok) {
return NextResponse.json({ error: "PDF generation failed" }, { status: res.status });
}
const pdf = await res.arrayBuffer();
return new NextResponse(pdf, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${invoiceName}.pdf"`,
},
});
}

View file

@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { createLead } from "@/lib/erpnext";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { lead_name, email_id, notes, source_form, page_origin } = body;
if (!lead_name || !email_id) {
return NextResponse.json(
{ error: "Name and email are required" },
{ status: 400 }
);
}
await createLead({
lead_name,
email_id,
notes,
source_form,
page_origin,
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Failed to create lead:", error);
return NextResponse.json(
{ error: "Failed to submit contact form" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,113 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { stripe, getOrCreateStripeCustomer, findActiveSubscription } from "@/lib/stripe";
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";
/**
* Stripe Price IDs for subscription plans.
* These need to be created in Stripe Dashboard as recurring prices.
* For now, placeholders replace with real IDs when Stripe subscription products are created.
*/
const SUBSCRIPTION_PRICES: Record<string, { priceId: string; label: string; interval: string }> = {
"paid-monthly": {
priceId: process.env.STRIPE_PRICE_PAID_MONTHLY || "price_paid_monthly_placeholder",
label: "DiDi Paid - Lunar",
interval: "month",
},
"paid-yearly": {
priceId: process.env.STRIPE_PRICE_PAID_YEARLY || "price_paid_yearly_placeholder",
label: "DiDi Paid - Anual",
interval: "year",
},
"enterprise-monthly": {
priceId: process.env.STRIPE_PRICE_ENTERPRISE_MONTHLY || "price_enterprise_monthly_placeholder",
label: "DiDi Enterprise - Lunar",
interval: "month",
},
"enterprise-yearly": {
priceId: process.env.STRIPE_PRICE_ENTERPRISE_YEARLY || "price_enterprise_yearly_placeholder",
label: "DiDi Enterprise - Anual",
interval: "year",
},
};
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const body = await request.json();
const planKey = body.planKey as string;
const plan = SUBSCRIPTION_PRICES[planKey];
if (!plan) {
return NextResponse.json({ error: "Invalid plan" }, { status: 400 });
}
// Check if price ID is a placeholder
if (plan.priceId.includes("placeholder")) {
return NextResponse.json(
{ error: "Subscriptiile recurente Stripe nu sunt inca configurate. Contacteaza administratorul." },
{ status: 501 }
);
}
const keycloakId = session.user.id;
const email = session.user.email || "";
const name = session.user.name || "";
try {
// A user can hold ONE subscription. Plan changes go through
// /api/subscription/manage (pro-rata), not through a second checkout.
const existing = await findActiveSubscription(keycloakId);
if (existing) {
return NextResponse.json(
{
error: "Ai deja un abonament activ. Schimba planul din pagina Abonament.",
subscription_id: existing.id,
current_plan_key: existing.metadata?.plan_key || "",
},
{ status: 409 }
);
}
const stripeCustomerId = await getOrCreateStripeCustomer({ keycloakId, email, name });
const checkoutSession = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_types: ["card"],
// The Stripe account has Managed Payments (Stripe = merchant of record) on by
// default; disable it per session: invoicing is done by TOP CLOSSERS through
// the ERP, and Managed Payments rejects payment_method_types anyway.
...({ managed_payments: { enabled: false } } as Record<string, unknown>),
line_items: [{ price: plan.priceId, quantity: 1 }],
customer: stripeCustomerId,
metadata: {
keycloak_id: keycloakId,
user_email: email,
user_name: name,
plan_key: planKey,
plan_label: plan.label,
plan_interval: plan.interval,
},
success_url: `${SITE_URL}/dashboard/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${SITE_URL}/dashboard/subscription`,
subscription_data: {
metadata: {
keycloak_id: keycloakId,
plan_key: planKey,
},
},
});
return NextResponse.json({ url: checkoutSession.url });
} catch (err) {
console.error("[Subscribe] Error:", err);
return NextResponse.json(
{ error: "Eroare la crearea sesiunii de plata" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,111 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { stripe, getOrCreateStripeCustomer, findActiveSubscription } from "@/lib/stripe";
const PRICE_IDS: Record<string, string> = {
"paid-monthly": process.env.STRIPE_PRICE_PAID_MONTHLY || "",
"paid-yearly": process.env.STRIPE_PRICE_PAID_YEARLY || "",
"enterprise-monthly": process.env.STRIPE_PRICE_ENTERPRISE_MONTHLY || "",
"enterprise-yearly": process.env.STRIPE_PRICE_ENTERPRISE_YEARLY || "",
};
/**
* POST /api/subscription/manage
* Body: { action: "upgrade" | "downgrade" | "cancel", planKey?: string }
*
* Upgrade/Downgrade: switches the active Stripe subscription to a new price
* with proration_behavior="create_prorations" so the customer is credited
* for unused time and charged a prorated amount for the new plan.
*
* Cancel: marks the subscription to cancel at period end (customer keeps
* access until current billing period ends, then downgrades to free_tier).
*/
export async function POST(request: NextRequest) {
const session = await auth();
const keycloakId = session?.user?.id;
const email = session?.user?.email;
if (!keycloakId || !email) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const body = await request.json();
const action = body.action as string;
const planKey = body.planKey as string | undefined;
// The user's Stripe Customer (keyed by keycloak_id) and active subscription, if any
const stripeCustomerId = await getOrCreateStripeCustomer({
keycloakId,
email,
name: session?.user?.name || "",
});
const activeSub = await findActiveSubscription(keycloakId);
try {
if (action === "cancel") {
if (!activeSub) {
return NextResponse.json({ error: "Niciun abonament activ de anulat" }, { status: 400 });
}
// Cancel at period end — keeps access until end of paid period
const updated = await stripe.subscriptions.update(activeSub.id, {
cancel_at_period_end: true,
});
return NextResponse.json({
success: true,
message: "Abonamentul va fi anulat la sfarsitul perioadei curente",
subscription_id: updated.id,
cancel_at: updated.cancel_at,
});
}
if (action === "upgrade" || action === "downgrade") {
if (!planKey || !PRICE_IDS[planKey]) {
return NextResponse.json({ error: "planKey invalid" }, { status: 400 });
}
const newPriceId = PRICE_IDS[planKey];
if (!activeSub) {
// No active sub yet — create a new Checkout Session for the new plan
const checkoutSession = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_types: ["card"],
// Managed Payments (Stripe = merchant of record) is on by default on the
// account; disable per session — invoicing is done through the ERP.
...({ managed_payments: { enabled: false } } as Record<string, unknown>),
line_items: [{ price: newPriceId, quantity: 1 }],
customer: stripeCustomerId,
metadata: {
keycloak_id: keycloakId,
plan_key: planKey,
},
subscription_data: {
metadata: { keycloak_id: keycloakId, plan_key: planKey },
},
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/dashboard/subscription?upgraded=1`,
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/dashboard/subscription`,
});
return NextResponse.json({ checkout_url: checkoutSession.url });
}
// Active sub exists — swap the price with pro-rata
const subItem = activeSub.items.data[0];
const updated = await stripe.subscriptions.update(activeSub.id, {
items: [{ id: subItem.id, price: newPriceId }],
proration_behavior: "create_prorations",
metadata: { ...activeSub.metadata, plan_key: planKey },
});
return NextResponse.json({
success: true,
message: `${action === "upgrade" ? "Upgrade" : "Downgrade"} efectuat cu pro-rata`,
subscription_id: updated.id,
new_plan_key: planKey,
});
}
return NextResponse.json({ error: "action invalid" }, { status: 400 });
} catch (err) {
const msg = err instanceof Error ? err.message : "Eroare necunoscuta";
console.error("[Subscription manage] Error:", msg);
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View file

@ -0,0 +1,282 @@
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { finalizeStripeCheckout, createPaidErpInvoice, withProcessLock } from "@/lib/stripe-fulfillment";
const ERPNEXT_URL = process.env.ERPNEXT_API_URL || "http://localhost:8080";
const API_KEY = process.env.ERPNEXT_API_KEY || "";
const API_SECRET = process.env.ERPNEXT_API_SECRET || "";
async function erpFetch(endpoint: string, options?: RequestInit) {
const res = await fetch(`${ERPNEXT_URL}${endpoint}`, {
...options,
headers: {
Authorization: `token ${API_KEY}:${API_SECRET}`,
"Content-Type": "application/json",
...options?.headers,
},
});
return res.json();
}
export async function POST(request: NextRequest) {
const body = await request.text();
const sig = request.headers.get("stripe-signature");
let event;
if (process.env.STRIPE_WEBHOOK_SECRET && sig) {
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET);
} catch {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
} else {
event = JSON.parse(body);
}
console.log(`[Stripe Webhook] Event: ${event.type}`);
// ── One-time payment completed ──
if (event.type === "checkout.session.completed") {
const session = event.data.object;
console.log("[Stripe Webhook] Payment completed:", {
email: session.metadata?.user_email,
service: session.metadata?.service_label || session.metadata?.plan_label,
amount: session.amount_total,
sessionId: session.id,
});
try {
const result = await finalizeStripeCheckout(session.id);
console.log("[Stripe Webhook] Fulfillment result:", result);
} catch (err) {
console.error("[Stripe Webhook] Error processing payment:", err);
}
}
// ── Subscription created (first activation) ──
if (event.type === "customer.subscription.created" || event.type === "customer.subscription.updated") {
const subscription = event.data.object;
const keycloakId = subscription.metadata?.keycloak_id;
const planKey = subscription.metadata?.plan_key;
if (keycloakId && planKey && subscription.status === "active") {
try {
const customers = await erpFetch(
`/api/resource/Customer?filters=[["didi_user_id","=","${encodeURIComponent(keycloakId)}"]]&fields=["name"]&limit_page_length=1`
);
const customerName = customers?.data?.[0]?.name;
if (customerName) {
const role = planKey.startsWith("enterprise") ? "enterprise_tier" : "paid_tier";
await erpFetch(`/api/resource/Customer/${encodeURIComponent(customerName)}`, {
method: "PUT",
body: JSON.stringify({
iam_role: role,
// Customer.active_plan is a Select (paid | enterprise), not the plan key
active_plan: role.replace("_tier", ""),
plan_activation_date: new Date().toISOString().slice(0, 10),
stripe_subscription_id: subscription.id,
}),
});
console.log(`[Stripe Webhook] Subscription ${event.type}: ${customerName}${role}`);
}
} catch (err) {
console.error("[Stripe Webhook] Error updating subscription:", err);
}
}
}
// ── Recurring invoice paid (subsequent billing cycles) ──
if (event.type === "invoice.paid" || event.type === "invoice.payment_succeeded") {
const invoice = event.data.object;
// Stripe API ≥ 2025-03 moved invoice.subscription to invoice.parent.subscription_details
// and invoice.payment_intent to invoice.payments[]; support both shapes.
const subscriptionId: string | undefined =
(typeof invoice.subscription === "string" ? invoice.subscription : invoice.subscription?.id) ||
invoice.parent?.subscription_details?.subscription;
const paymentIntentId: string =
(typeof invoice.payment_intent === "string" ? invoice.payment_intent : invoice.payment_intent?.id) ||
invoice.payments?.data?.[0]?.payment?.payment_intent ||
"";
// Only handle subscription invoices, skip first-cycle (handled by checkout.session.completed).
// invoice.paid and invoice.payment_succeeded arrive at the same time for the same
// invoice → serialize per Stripe invoice id so the idempotency check below holds.
if (invoice.billing_reason === "subscription_cycle" && subscriptionId) {
await withProcessLock(`renewal:${invoice.id}`, async () => {
try {
// Fetch subscription metadata
const sub = await stripe.subscriptions.retrieve(subscriptionId);
const keycloakId = sub.metadata?.keycloak_id;
const planKey = sub.metadata?.plan_key || "";
if (keycloakId) {
// Idempotency: Stripe retries webhooks and sends both invoice.paid and
// invoice.payment_succeeded — one ERP invoice per Stripe invoice id
const existing = await erpFetch(
`/api/resource/Payment%20Log?filters=[["stripe_session_id","=","${encodeURIComponent(invoice.id)}"]]&fields=["name"]&limit_page_length=1`
);
if (existing?.data?.[0]) {
console.log(`[Stripe Webhook] Renewal ${invoice.id} already processed`);
} else {
const customers = await erpFetch(
`/api/resource/Customer?filters=[["didi_user_id","=","${encodeURIComponent(keycloakId)}"]]&fields=["name"]&limit_page_length=1`
);
const customerName = customers?.data?.[0]?.name as string | undefined;
const itemCode = planKey.startsWith("enterprise") ? "DIDI-ENTERPRISE" : "DIDI-PAID";
const label = planKey.startsWith("enterprise") ? "DiDi Enterprise" : "DiDi Paid";
const amount = (invoice.amount_paid || 0) / 100;
// Same invoice + payment entry + email as the first payment, for every renewal cycle
let erpInvoice: { invoiceName: string; paymentEntryName: string } | null = null;
if (customerName && amount > 0) {
erpInvoice = await createPaidErpInvoice({
customerName,
itemCode,
serviceLabel: `${label} - reinnoire ${planKey.endsWith("yearly") ? "anuala" : "lunara"}`,
rate: amount,
referenceNo: paymentIntentId || invoice.id,
}).catch((err) => {
console.error("[Stripe Webhook] Renewal ERP invoice failed:", err);
return null;
});
}
await erpFetch("/api/resource/Payment%20Log", {
method: "POST",
body: JSON.stringify({
customer: customerName || "",
event_type: "payment_intent.succeeded",
status: "Succeeded",
amount,
currency: (invoice.currency || "ron").toUpperCase(),
stripe_payment_intent_id: paymentIntentId,
stripe_subscription_id: subscriptionId,
stripe_session_id: invoice.id, // use invoice id as idempotency key
raw_webhook_data: JSON.stringify({
event_subtype: "invoice.paid",
plan_key: planKey,
subscription: subscriptionId,
billing_reason: "subscription_cycle",
invoice_name: erpInvoice?.invoiceName || null,
payment_entry_name: erpInvoice?.paymentEntryName || null,
}, null, 2).slice(0, 10000),
}),
});
console.log(`[Stripe Webhook] Recurring payment logged: ${invoice.id} (${planKey}) → ${erpInvoice?.invoiceName || "no ERP invoice"}`);
}
}
} catch (err) {
console.error("[Stripe Webhook] Error logging recurring payment:", err);
}
});
}
}
// ── Subscription updated (LEGACY HANDLER - kept for explicit upgrade/downgrade webhooks if status changes) ──
if (event.type === "customer.subscription.updated___DISABLED") {
const subscription = event.data.object;
const keycloakId = subscription.metadata?.keycloak_id;
const planKey = subscription.metadata?.plan_key;
console.log("[Stripe Webhook] Subscription updated:", {
keycloakId,
planKey,
status: subscription.status,
});
if (keycloakId && planKey) {
try {
const customers = await erpFetch(
`/api/resource/Customer?filters=[["didi_user_id","=","${encodeURIComponent(keycloakId)}"]]&fields=["name"]&limit_page_length=1`
);
const customerName = customers?.data?.[0]?.name;
if (customerName) {
const role = planKey.startsWith("enterprise") ? "enterprise_tier" : "paid_tier";
await erpFetch(`/api/resource/Customer/${encodeURIComponent(customerName)}`, {
method: "PUT",
body: JSON.stringify({
iam_role: role,
// Customer.active_plan is a Select (paid | enterprise), not the plan key
active_plan: role.replace("_tier", ""),
plan_activation_date: new Date().toISOString().slice(0, 10),
stripe_subscription_id: subscription.id,
}),
});
console.log("[Stripe Webhook] Customer plan updated:", customerName, role);
}
} catch (err) {
console.error("[Stripe Webhook] Error updating subscription:", err);
}
}
}
// ── Subscription cancelled/deleted ──
if (event.type === "customer.subscription.deleted") {
const subscription = event.data.object;
const keycloakId = subscription.metadata?.keycloak_id;
console.log("[Stripe Webhook] Subscription cancelled:", { keycloakId });
if (keycloakId) {
try {
const customers = await erpFetch(
`/api/resource/Customer?filters=[["didi_user_id","=","${encodeURIComponent(keycloakId)}"]]&fields=["name"]&limit_page_length=1`
);
const customerName = customers?.data?.[0]?.name;
if (customerName) {
await erpFetch(`/api/resource/Customer/${encodeURIComponent(customerName)}`, {
method: "PUT",
body: JSON.stringify({ iam_role: "free_tier", active_plan: "" }),
});
console.log("[Stripe Webhook] Customer downgraded to free:", customerName);
}
} catch (err) {
console.error("[Stripe Webhook] Error cancelling subscription:", err);
}
}
}
// ── Payment failed on subscription invoice ──
if (event.type === "invoice.payment_failed") {
const invoice = event.data.object;
const customerEmail = invoice.customer_email;
console.log("[Stripe Webhook] Payment failed:", { customerEmail, amount: invoice.amount_due });
// Log in ERPNext
try {
await erpFetch("/api/resource/Payment%20Log", {
method: "POST",
body: JSON.stringify({
event_type: "invoice.payment_failed",
status: "Failed",
amount: (invoice.amount_due || 0) / 100,
currency: (invoice.currency || "ron").toUpperCase(),
stripe_payment_intent_id: invoice.payment_intent || "",
raw_webhook_data: JSON.stringify(invoice, null, 2).slice(0, 10000),
}),
});
} catch (err) {
console.error("[Stripe Webhook] Error logging failed payment:", err);
}
// Notify the customer with a reactivation link (ref CS II.5.2)
if (customerEmail) {
try {
await erpFetch("/api/method/didi_custom.notifications.send_payment_failed_email", {
method: "POST",
body: JSON.stringify({
email: customerEmail,
amount: (invoice.amount_due || 0) / 100,
currency: (invoice.currency || "ron").toUpperCase(),
reactivation_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing`,
}),
});
console.log("[Stripe Webhook] Payment-failed email sent to:", customerEmail);
} catch (err) {
console.error("[Stripe Webhook] Payment-failed email error:", err);
}
}
}
return NextResponse.json({ received: true });
}

BIN
website/src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -0,0 +1,36 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans), Arial, Helvetica, sans-serif;
}
/* WCAG 2.1 AA — focus visible on all interactive elements */
*:focus-visible {
outline: 2px solid #0d9488;
outline-offset: 2px;
border-radius: 4px;
}
/* Reduce motion for prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

View file

@ -0,0 +1,52 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import SessionProvider from "@/components/providers/SessionProvider";
import CookieConsent from "@/components/ui/CookieConsent";
import { getServerLocale } from "@/i18n/server";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: {
default: "Clossers - Platforma DiDi pentru Combaterea Dezinformarii",
template: "%s | Clossers",
},
description:
"Clossers ofera platforma DiDi - analiza automata a continutului media prin inteligenta artificiala pentru detectarea dezinformarii si verificarea informatiilor in timp real.",
keywords: ["dezinformare", "fact-checking", "AI", "analiza media", "DiDi", "Clossers"],
openGraph: {
siteName: "Clossers",
locale: "ro_RO",
type: "website",
},
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const locale = await getServerLocale();
return (
<html
lang={locale}
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col font-[family-name:var(--font-geist-sans)]">
<SessionProvider>
{children}
<CookieConsent />
</SessionProvider>
</body>
</html>
);
}

20
website/src/app/page.tsx Normal file
View file

@ -0,0 +1,20 @@
import Navbar from "@/components/layout/Navbar";
import Footer from "@/components/layout/Footer";
import PnrrBanner from "@/components/layout/PnrrBanner";
import HomePage from "./(public)/page";
export default function RootPage() {
return (
<>
<a href="#main-content" className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[100] focus:rounded-lg focus:bg-teal-600 focus:px-4 focus:py-2 focus:text-white focus:outline-none">
Salt la continut
</a>
<PnrrBanner />
<Navbar />
<main id="main-content" className="flex-1" tabIndex={-1}>
<HomePage />
</main>
<Footer />
</>
);
}

16
website/src/app/robots.ts Normal file
View file

@ -0,0 +1,16 @@
import type { MetadataRoute } from "next";
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/dashboard/", "/api/", "/login", "/register"],
},
],
sitemap: `${BASE_URL}/sitemap.xml`,
};
}

View file

@ -0,0 +1,13 @@
import type { MetadataRoute } from "next";
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";
export default function sitemap(): MetadataRoute.Sitemap {
return [
{ url: BASE_URL, lastModified: new Date(), changeFrequency: "weekly", priority: 1 },
{ url: `${BASE_URL}/pricing`, lastModified: new Date(), changeFrequency: "weekly", priority: 0.9 },
{ url: `${BASE_URL}/contact`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.7 },
{ url: `${BASE_URL}/privacy`, lastModified: new Date(), changeFrequency: "yearly", priority: 0.3 },
{ url: `${BASE_URL}/terms`, lastModified: new Date(), changeFrequency: "yearly", priority: 0.3 },
];
}

View file

@ -0,0 +1,98 @@
"use client";
import { useState } from "react";
export default function ContactForm() {
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus("loading");
const form = new FormData(e.currentTarget);
const data = {
lead_name: form.get("name") as string,
email_id: form.get("email") as string,
notes: form.get("message") as string,
source_form: "contact",
page_origin: "/contact",
};
try {
const res = await fetch("/api/leads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Failed");
setStatus("success");
} catch {
setStatus("error");
}
}
if (status === "success") {
return (
<div role="status" aria-live="polite" className="rounded-xl border border-green-200 bg-green-50 p-6 text-center">
<p className="text-lg font-semibold text-green-800">Mesaj trimis!</p>
<p className="mt-2 text-sm text-green-700">
Echipa noastra te va contacta in cel mai scurt timp.
</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Nume complet
</label>
<input
id="name"
name="name"
type="text"
required
className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-teal-500 focus:ring-teal-500"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-teal-500 focus:ring-teal-500"
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium text-gray-700">
Mesaj
</label>
<textarea
id="message"
name="message"
rows={4}
required
className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-teal-500 focus:ring-teal-500"
/>
</div>
<button
type="submit"
disabled={status === "loading"}
className="w-full rounded-lg bg-teal-600 py-3 text-sm font-semibold text-white hover:bg-teal-700 disabled:opacity-50"
>
{status === "loading" ? "Se trimite..." : "Trimite mesaj"}
</button>
{status === "error" && (
<p role="alert" className="text-sm text-red-700">
A aparut o eroare. Te rugam sa incerci din nou.
</p>
)}
</form>
);
}

View file

@ -0,0 +1,60 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import SignOutButton from "@/components/ui/SignOutButton";
const links = [
{ href: "/dashboard", label: "Overview" },
{ href: "/dashboard/analize", label: "Analize" },
{ href: "/dashboard/invoices", label: "Facturi" },
{ href: "/dashboard/subscription", label: "Abonament" },
{ href: "/dashboard/profile", label: "Profil" },
];
export default function DashboardSidebar({ userName }: { userName?: string | null }) {
const pathname = usePathname();
return (
<aside className="hidden w-64 shrink-0 border-r border-black/5 bg-white lg:flex lg:flex-col">
<div className="border-b border-black/5 px-5 py-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-teal-600">Workspace</p>
<p className="mt-1 text-sm text-gray-500">Administrare cont si achizitii</p>
</div>
<nav aria-label="Navigare cont" className="flex flex-1 flex-col gap-1 px-3 py-4">
{links.map((l) => {
const active = pathname === l.href;
return (
<Link
key={l.href}
href={l.href}
className={`rounded-xl px-3 py-2.5 text-sm font-medium transition-colors ${
active
? "bg-teal-50 text-teal-700 shadow-sm ring-1 ring-teal-100"
: "text-gray-700 hover:bg-stone-50"
}`}
>
{l.label}
</Link>
);
})}
</nav>
<div className="border-t border-black/5 p-4">
<div className="rounded-2xl border border-black/5 bg-stone-50 p-3">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-teal-100 text-sm font-semibold text-teal-700">
{userName?.[0]?.toUpperCase() || "U"}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-gray-900">{userName || "User"}</p>
<p className="truncate text-xs text-gray-500">Cont activ</p>
</div>
</div>
<SignOutButton className="mt-3 w-full rounded-xl border border-black/5 bg-white px-3 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-stone-100 hover:text-gray-900" />
</div>
</div>
</aside>
);
}

View file

@ -0,0 +1,101 @@
"use client";
import Link from "next/link";
import { useLocale } from "@/i18n/useLocale";
export default function Footer() {
const { t } = useLocale();
return (
<footer className="mt-auto border-t border-gray-200 bg-gray-50">
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="flex flex-col items-center gap-6">
{/* Logo */}
<img src="/logo.png" alt="Clossers" className="h-8" />
{/* Links */}
<nav aria-label="Navigare footer" className="flex flex-wrap justify-center gap-6">
<Link href="/privacy" className="text-sm text-gray-500 hover:text-gray-700">
{t.footer.privacy}
</Link>
<Link href="/terms" className="text-sm text-gray-500 hover:text-gray-700">
{t.footer.terms}
</Link>
<Link href="/contact" className="text-sm text-gray-500 hover:text-gray-700">
{t.footer.contact}
</Link>
</nav>
{/* Retele sociale */}
<nav aria-label="Retele sociale" className="flex justify-center gap-3">
<a
href="https://www.facebook.com/profile.php?id=61590102614876"
target="_blank"
rel="noopener noreferrer"
aria-label="Facebook"
title="Facebook"
className="flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 text-gray-400 transition-colors hover:border-teal-600 hover:text-teal-600"
>
<svg viewBox="0 0 24 24" fill="currentColor" className="h-4 w-4" aria-hidden="true">
<path d="M24 12.07C24 5.4 18.63 0 12 0S0 5.4 0 12.07C0 18.1 4.39 23.1 10.13 24v-8.44H7.08v-3.49h3.05V9.41c0-3.02 1.79-4.69 4.53-4.69 1.31 0 2.68.24 2.68.24v2.97h-1.51c-1.49 0-1.96.93-1.96 1.89v2.25h3.33l-.53 3.49h-2.8V24C19.61 23.1 24 18.1 24 12.07" />
</svg>
</a>
<a
href="https://www.linkedin.com/company/135296497/"
target="_blank"
rel="noopener noreferrer"
aria-label="LinkedIn"
title="LinkedIn"
className="flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 text-gray-400 transition-colors hover:border-teal-600 hover:text-teal-600"
>
<svg viewBox="0 0 24 24" fill="currentColor" className="h-4 w-4" aria-hidden="true">
<path d="M20.45 20.45h-3.56v-5.57c0-1.33-.02-3.04-1.85-3.04-1.85 0-2.13 1.45-2.13 2.94v5.67H9.35V9h3.42v1.56h.05c.48-.9 1.64-1.85 3.37-1.85 3.6 0 4.27 2.37 4.27 5.46v6.28zM5.34 7.43a2.06 2.06 0 1 1 0-4.13 2.06 2.06 0 0 1 0 4.13zM7.12 20.45H3.56V9h3.56v11.45zM22.22 0H1.77C.79 0 0 .77 0 1.73v20.54C0 23.23.79 24 1.77 24h20.45c.98 0 1.78-.77 1.78-1.73V1.73C24 .77 23.2 0 22.22 0z" />
</svg>
</a>
</nav>
{/* Bloc obligatoriu PNRR (MIV 5.7) — textele oficiale rămân în română */}
<div className="w-full border-t border-gray-200 pt-6 text-center">
<img
src="/pnrr-eu-nextgen.png"
alt="Finanțat de Uniunea Europeană NextGenerationEU"
className="mx-auto h-16 w-auto max-w-full object-contain"
/>
<p className="mt-3 text-sm font-semibold text-gray-700">
PNRR. Finanțat de Uniunea Europeană UrmătoareaGenerațieUE
</p>
<p className="mx-auto mt-1 max-w-2xl text-xs text-gray-500">
&bdquo;Conținutul acestui material nu reprezintă în mod obligatoriu poziția
oficială a Uniunii Europene sau a Guvernului României&rdquo;
</p>
<p className="mt-2 text-xs">
<a
href="https://mfe.gov.ro/pnrr/"
target="_blank"
rel="noopener noreferrer"
className="text-teal-700 underline hover:text-teal-900"
>
mfe.gov.ro/pnrr
</a>
<span className="mx-2 text-gray-400">|</span>
<a
href="https://www.facebook.com/PNRROficial"
target="_blank"
rel="noopener noreferrer"
className="text-teal-700 underline hover:text-teal-900"
>
facebook.com/PNRROficial
</a>
</p>
</div>
{/* Copyright */}
<div className="text-center text-xs text-gray-500">
<p>&copy; {new Date().getFullYear()} TOP CLOSSERS SRL. {t.footer.rights}</p>
<p className="mt-1">CUI: 36193026 | Ploiesti, Romania</p>
</div>
</div>
</div>
</footer>
);
}

View file

@ -0,0 +1,125 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { useSession, signOut } from "next-auth/react";
import { useLocale } from "@/i18n/useLocale";
export default function Navbar() {
const [mobileOpen, setMobileOpen] = useState(false);
const { locale, t, switchLocale } = useLocale();
const { data: session } = useSession();
const user = session?.user;
const navLinks = [
{ href: "/services", label: "Servicii" },
{ href: "/about", label: "Despre" },
{ href: "/pricing", label: t.nav.pricing },
{ href: "/contact", label: t.nav.contact },
];
return (
<header className="sticky top-0 z-50 border-b border-gray-200 bg-white/80 backdrop-blur">
<nav aria-label="Navigare principala" className="relative mx-auto flex h-16 max-w-7xl items-center px-4 sm:px-6 lg:px-8">
<Link href="/" className="flex items-center">
<img src="/logo.png" alt="Clossers" className="h-8" />
</Link>
<ul className="hidden flex-1 items-center justify-center gap-8 lg:flex">
{navLinks.map((l) => (
<li key={l.href}>
<Link href={l.href} className="text-sm font-medium text-gray-700 hover:text-teal-600">{l.label}</Link>
</li>
))}
</ul>
<div className="ml-auto hidden shrink-0 items-center gap-3 lg:flex">
<button
onClick={() => switchLocale(locale === "ro" ? "en" : "ro")}
aria-label={locale === "ro" ? "Schimba limba in engleza" : "Switch language to Romanian"}
className="rounded border px-2.5 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-50 leading-none"
>
{locale === "ro" ? "EN" : "RO"}
</button>
{user ? (
<>
<Link href="/dashboard" className="text-sm font-medium text-gray-700 hover:text-teal-600 leading-none">
Dashboard
</Link>
<button
onClick={() => signOut({ callbackUrl: "/" })}
className="text-sm font-medium text-gray-500 hover:text-gray-700 leading-none"
>
{t.nav.logout}
</button>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-teal-100 text-sm font-semibold text-teal-700">
{user.name?.[0]?.toUpperCase() || "U"}
</div>
</>
) : (
<>
<Link href="/login" className="text-sm font-medium text-gray-700 hover:text-teal-600 leading-none">
{t.nav.login}
</Link>
<Link href="/register" className="text-sm font-medium text-teal-700 hover:text-teal-900 leading-none">
Inregistrare
</Link>
<Link href="/pricing" className="rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-700 leading-none">
{t.nav.start_free}
</Link>
</>
)}
</div>
<button
onClick={() => setMobileOpen(!mobileOpen)}
className="ml-auto lg:hidden"
aria-label={mobileOpen ? "Inchide meniul" : "Deschide meniul"}
aria-expanded={mobileOpen}
aria-controls="mobile-menu"
>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
{mobileOpen ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
)}
</svg>
</button>
</nav>
{mobileOpen && (
<div id="mobile-menu" className="border-t border-gray-200 bg-white px-4 py-4 lg:hidden">
<ul className="space-y-3">
{navLinks.map((l) => (
<li key={l.href}>
<Link href={l.href} className="block text-base font-medium text-gray-700" onClick={() => setMobileOpen(false)}>{l.label}</Link>
</li>
))}
{user && (
<li>
<Link href="/dashboard" className="block text-base font-medium text-teal-600" onClick={() => setMobileOpen(false)}>Dashboard</Link>
</li>
)}
{!user && (
<li className="border-t pt-3">
<Link href="/register" className="block text-base font-medium text-teal-700" onClick={() => setMobileOpen(false)}>Inregistrare</Link>
</li>
)}
<li className="flex items-center justify-between border-t pt-3">
{user ? (
<button onClick={() => signOut({ callbackUrl: "/" })} className="text-base font-medium text-gray-500">{t.nav.logout}</button>
) : (
<Link href="/login" className="text-base font-medium text-teal-600" onClick={() => setMobileOpen(false)}>{t.nav.login}</Link>
)}
<button onClick={() => switchLocale(locale === "ro" ? "en" : "ro")} className="rounded border px-2 py-1 text-xs font-medium text-gray-600">
{locale === "ro" ? "EN" : "RO"}
</button>
</li>
</ul>
</div>
)}
</header>
);
}

View file

@ -0,0 +1,36 @@
/**
* Banner obligatoriu PNRR (MIV secțiunea 5.7 Pagina web):
* logo UE Finanțat de Uniunea Europeană NextGenerationEU" + sigla Guvernului
* României + logo PNRR, în această ordine, afișate policrom în partea de sus
* a paginii, vizibile fără derulare. Siglele sunt distribuite pe aceeași
* lățime de container ca meniul, ca fie aliniate cu restul header-ului.
*/
export default function PnrrBanner() {
return (
<div className="w-full border-b border-gray-200 bg-white">
<a
href="https://mfe.gov.ro/pnrr/"
target="_blank"
rel="noopener noreferrer"
aria-label="Planul Național de Redresare și Reziliență — pagina oficială a programului"
className="mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-2 sm:px-6 lg:px-8"
>
<img
src="/pnrr-logo-eu.png"
alt="Finanțat de Uniunea Europeană NextGenerationEU"
className="h-8 w-auto object-contain sm:h-11"
/>
<img
src="/pnrr-logo-guv.png"
alt="Guvernul României"
className="h-11 w-auto object-contain sm:h-16"
/>
<img
src="/pnrr-logo-pnrr.png"
alt="Planul Național de Redresare și Reziliență"
className="h-10 w-auto object-contain sm:h-14"
/>
</a>
</div>
);
}

View file

@ -0,0 +1,11 @@
"use client";
import { SessionProvider as NextAuthSessionProvider } from "next-auth/react";
export default function SessionProvider({
children,
}: {
children: React.ReactNode;
}) {
return <NextAuthSessionProvider>{children}</NextAuthSessionProvider>;
}

View file

@ -0,0 +1,107 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
type CookiePrefs = {
necessary: true;
functional: boolean;
analytics: boolean;
};
const COOKIE_KEY = "didi_cookie_consent";
export default function CookieConsent() {
const [show, setShow] = useState(false);
const [expanded, setExpanded] = useState(false);
const [prefs, setPrefs] = useState<CookiePrefs>({
necessary: true,
functional: false,
analytics: false,
});
useEffect(() => {
const saved = localStorage.getItem(COOKIE_KEY);
if (!saved) setShow(true);
}, []);
function accept(all: boolean) {
const final: CookiePrefs = all
? { necessary: true, functional: true, analytics: true }
: prefs;
localStorage.setItem(COOKIE_KEY, JSON.stringify(final));
setShow(false);
}
if (!show) return null;
return (
<div role="region" aria-label="Consimtamant cookie-uri" className="fixed inset-x-0 bottom-0 z-50 border-t bg-white p-4 shadow-lg sm:p-6">
<div className="mx-auto max-w-4xl">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex-1">
<p className="text-sm font-semibold text-gray-900">Cookies</p>
<p className="mt-1 text-sm text-gray-600">
Folosim cookie-uri pentru a asigura functionarea site-ului si, cu acordul tau, pentru analiza traficului.{" "}
<Link href="/privacy" className="text-teal-600 hover:underline">
Politica de confidentialitate
</Link>
</p>
{expanded && (
<div className="mt-4 space-y-3">
<label className="flex items-center gap-3">
<input type="checkbox" checked disabled className="accent-teal-600" />
<span className="text-sm"><strong>Necesare</strong> functionarea de baza a site-ului (intotdeauna active)</span>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={prefs.functional}
onChange={(e) => setPrefs({ ...prefs, functional: e.target.checked })}
className="accent-teal-600"
/>
<span className="text-sm"><strong>Functionale</strong> preferinte limba, sesiune login</span>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={prefs.analytics}
onChange={(e) => setPrefs({ ...prefs, analytics: e.target.checked })}
className="accent-teal-600"
/>
<span className="text-sm"><strong>Analitice</strong> statistici anonime de utilizare</span>
</label>
</div>
)}
</div>
<div className="flex shrink-0 flex-col gap-2 sm:flex-row">
{!expanded && (
<button
onClick={() => setExpanded(true)}
className="rounded-lg border px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Personalizeaza
</button>
)}
{expanded && (
<button
onClick={() => accept(false)}
className="rounded-lg border px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Salveaza selectia
</button>
)}
<button
onClick={() => accept(true)}
className="rounded-lg bg-teal-600 px-4 py-2 text-sm font-semibold text-white hover:bg-teal-700"
>
Accepta toate
</button>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,14 @@
"use client";
import { signOut } from "next-auth/react";
export default function SignOutButton({ className = "text-sm text-gray-500 hover:text-gray-700" }: { className?: string }) {
return (
<button
onClick={() => signOut({ callbackUrl: "/" })}
className={className}
>
Logout
</button>
);
}

80
website/src/i18n/en.json Normal file
View file

@ -0,0 +1,80 @@
{
"nav": {
"home": "Home",
"pricing": "Pricing",
"contact": "Contact",
"login": "Sign in",
"register": "Sign up",
"start_free": "Start free",
"logout": "Sign out",
"site": "Site"
},
"home": {
"hero_title": "Fight disinformation with artificial intelligence",
"hero_subtitle": "DiDi automatically analyzes media content — text, images, audio and video — to detect disinformation and verify information in real time.",
"start_free": "Start free",
"view_plans": "View plans",
"features_title": "Features",
"how_title": "How it works",
"cta_title": "Ready to fight disinformation?",
"cta_sub": "Start for free and discover the power of AI analysis.",
"cta_btn": "Start now"
},
"pricing": {
"title": "Plans and pricing",
"subtitle": "Choose the right plan for your information verification needs.",
"free": "Free",
"per_month": "RON / month",
"start_free": "Start free",
"choose_plan": "Choose plan",
"popular": "Popular"
},
"contact": {
"title": "Contact us",
"subtitle": "Have questions? Our team is here to help.",
"name": "Full name",
"email": "Email",
"message": "Message",
"send": "Send message",
"sending": "Sending...",
"success_title": "Message sent!",
"success_sub": "Our team will contact you shortly.",
"error": "An error occurred. Please try again."
},
"footer": {
"privacy": "Privacy",
"terms": "Terms",
"contact": "Contact",
"rights": "All rights reserved."
},
"dashboard": {
"title": "Dashboard",
"welcome": "Welcome",
"active_plan": "Active plan",
"credits": "Available credits",
"invoices": "Invoices",
"invoices_issued": "Invoices issued",
"recent_invoices": "Recent invoices",
"view_all": "View all",
"upgrade": "Upgrade plan",
"no_invoices": "No invoices yet.",
"overview": "Overview",
"subscription": "Subscription",
"profile": "Profile",
"credits_page": "Credits",
"checkout": "Checkout"
},
"cookie": {
"title": "Cookies",
"text": "We use cookies to ensure the site works properly and, with your consent, to analyze traffic.",
"necessary": "Necessary",
"necessary_desc": "basic site functionality (always active)",
"functional": "Functional",
"functional_desc": "language preferences, login session",
"analytics": "Analytics",
"analytics_desc": "anonymous usage statistics",
"customize": "Customize",
"save": "Save selection",
"accept_all": "Accept all"
}
}

21
website/src/i18n/index.ts Normal file
View file

@ -0,0 +1,21 @@
import ro from "./ro.json";
import en from "./en.json";
export type Locale = "ro" | "en";
export const LOCALE_COOKIE = "didi_locale";
export const LOCALE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
export const SUPPORTED_LOCALES: Locale[] = ["ro", "en"];
export const DEFAULT_LOCALE: Locale = "ro";
const dictionaries: Record<Locale, typeof ro> = { ro, en };
export function getDictionary(locale: Locale) {
return dictionaries[locale] || dictionaries[DEFAULT_LOCALE];
}
export function isLocale(value: string | undefined | null): value is Locale {
return value === "ro" || value === "en";
}
export type Dictionary = typeof ro;

80
website/src/i18n/ro.json Normal file
View file

@ -0,0 +1,80 @@
{
"nav": {
"home": "Acasa",
"pricing": "Pricing",
"contact": "Contact",
"login": "Conecteaza-te",
"register": "Inregistreaza-te",
"start_free": "Incepe gratuit",
"logout": "Deconectare",
"site": "Site"
},
"home": {
"hero_title": "Combate dezinformarea cu inteligenta artificiala",
"hero_subtitle": "DiDi analizeaza automat continut media — text, imagini, audio si video — pentru a detecta dezinformarea si a verifica informatiile in timp real.",
"start_free": "Incepe gratuit",
"view_plans": "Vezi planuri",
"features_title": "Functionalitati",
"how_title": "Cum functioneaza",
"cta_title": "Pregatit sa combati dezinformarea?",
"cta_sub": "Incepe gratuit si descopera puterea analizei AI.",
"cta_btn": "Incepe acum"
},
"pricing": {
"title": "Planuri si preturi",
"subtitle": "Alege planul potrivit pentru nevoile tale de verificare a informatiilor.",
"free": "Gratuit",
"per_month": "RON / luna",
"start_free": "Incepe gratuit",
"choose_plan": "Alege planul",
"popular": "Popular"
},
"contact": {
"title": "Contacteaza-ne",
"subtitle": "Ai intrebari? Echipa noastra iti sta la dispozitie.",
"name": "Nume complet",
"email": "Email",
"message": "Mesaj",
"send": "Trimite mesaj",
"sending": "Se trimite...",
"success_title": "Mesaj trimis!",
"success_sub": "Echipa noastra te va contacta in cel mai scurt timp.",
"error": "A aparut o eroare. Te rugam sa incerci din nou."
},
"footer": {
"privacy": "Confidentialitate",
"terms": "Termeni",
"contact": "Contact",
"rights": "Toate drepturile rezervate."
},
"dashboard": {
"title": "Dashboard",
"welcome": "Bine ai venit",
"active_plan": "Plan activ",
"credits": "Credite disponibile",
"invoices": "Facturi",
"invoices_issued": "Facturi emise",
"recent_invoices": "Ultimele facturi",
"view_all": "Vezi toate",
"upgrade": "Upgrade plan",
"no_invoices": "Nicio factura emisa inca.",
"overview": "Overview",
"subscription": "Abonament",
"profile": "Profil",
"credits_page": "Credite",
"checkout": "Checkout"
},
"cookie": {
"title": "Cookies",
"text": "Folosim cookie-uri pentru a asigura functionarea site-ului si, cu acordul tau, pentru analiza traficului.",
"necessary": "Necesare",
"necessary_desc": "functionarea de baza a site-ului (intotdeauna active)",
"functional": "Functionale",
"functional_desc": "preferinte limba, sesiune login",
"analytics": "Analitice",
"analytics_desc": "statistici anonime de utilizare",
"customize": "Personalizeaza",
"save": "Salveaza selectia",
"accept_all": "Accepta toate"
}
}

View file

@ -0,0 +1,21 @@
import "server-only";
import { cookies } from "next/headers";
import { type Locale, DEFAULT_LOCALE, LOCALE_COOKIE, getDictionary, isLocale } from "./index";
/**
* Read locale from cookie in a Server Component / Route Handler.
* Falls back to DEFAULT_LOCALE if cookie missing or invalid.
*/
export async function getServerLocale(): Promise<Locale> {
const c = await cookies();
const v = c.get(LOCALE_COOKIE)?.value;
return isLocale(v) ? v : DEFAULT_LOCALE;
}
/**
* Convenience: read locale + return dictionary in one call.
*/
export async function getServerDictionary() {
const locale = await getServerLocale();
return { locale, t: getDictionary(locale) };
}

View file

@ -0,0 +1,47 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import {
type Locale,
type Dictionary,
DEFAULT_LOCALE,
LOCALE_COOKIE,
LOCALE_COOKIE_MAX_AGE,
getDictionary,
isLocale,
} from "./index";
function readClientLocale(): Locale {
if (typeof document === "undefined") return DEFAULT_LOCALE;
const match = document.cookie.match(new RegExp(`(?:^|; )${LOCALE_COOKIE}=([^;]*)`));
const v = match?.[1];
return isLocale(v) ? v : DEFAULT_LOCALE;
}
function writeClientLocale(locale: Locale) {
if (typeof document === "undefined") return;
document.cookie = `${LOCALE_COOKIE}=${locale}; path=/; max-age=${LOCALE_COOKIE_MAX_AGE}; samesite=lax`;
}
export function useLocale() {
const router = useRouter();
const [locale, setLocaleState] = useState<Locale>(DEFAULT_LOCALE);
const [t, setT] = useState<Dictionary>(getDictionary(DEFAULT_LOCALE));
useEffect(() => {
const l = readClientLocale();
setLocaleState(l);
setT(getDictionary(l));
}, []);
function switchLocale(newLocale: Locale) {
writeClientLocale(newLocale);
setLocaleState(newLocale);
setT(getDictionary(newLocale));
// Soft refresh — re-renders RSC with new locale cookie. No full page reload.
router.refresh();
}
return { locale, t, switchLocale };
}

View file

@ -0,0 +1,13 @@
export function formatAnalysisLabel(value: string) {
return String(value || "")
.split(".")
.filter(Boolean)
.map((part) =>
part
.split(/[_-]+/)
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" "),
)
.join(" / ");
}

View file

@ -0,0 +1,217 @@
import { jsPDF } from "jspdf";
import { formatAnalysisLabel } from "@/lib/analysis-display";
type AnalysisMetadata = {
invoice: string;
component: string;
media: string;
locale?: "ro" | "en";
};
const SERVICE_LABELS: Record<string, string> = {
TECHNIQUES: "Detectie Tehnici de Manipulare",
AI_DETECTION: "AI Detection & Deepfake",
CLAIMS: "Verificare Afirmatii (Fact-Checking)",
SOURCE: "Evaluare Surse & Domeniu",
};
const MEDIA_LABELS: Record<string, string> = {
TEXT: "Text",
IMAGE: "Imagine",
AUDIO: "Audio",
VIDEO: "Video",
URL: "URL",
};
function pdfSafeText(value: unknown) {
return String(value ?? "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "");
}
export function buildAnalysisReportPdf(
result: Record<string, unknown>,
selected: AnalysisMetadata,
) {
const pdf = new jsPDF({ unit: "mm", format: "a4" });
const pw = pdf.internal.pageSize.getWidth();
const m = 20;
const cw = pw - m * 2;
let y = 20;
const verdict = (result.verdict || {}) as Record<string, unknown>;
function checkPage(space: number) {
if (y + space > 272) {
pdf.addPage();
y = 20;
}
}
function section(title: string) {
checkPage(15);
pdf.setFillColor(240, 253, 250);
pdf.rect(m, y - 1, cw, 8, "F");
pdf.setFontSize(12);
pdf.setFont("helvetica", "bold");
pdf.setTextColor(15, 118, 110);
pdf.text(pdfSafeText(title), m + 3, y + 5);
pdf.setTextColor(51, 51, 51);
y += 12;
}
function kv(key: string, val: string) {
checkPage(8);
pdf.setFontSize(9);
pdf.setFont("helvetica", "bold");
pdf.text(pdfSafeText(`${key}:`), m + 2, y);
pdf.setFont("helvetica", "normal");
const lines = pdf.splitTextToSize(pdfSafeText(val || "-"), cw - 45);
pdf.text(lines, m + 45, y);
y += Math.max(6, lines.length * 4.5);
}
function para(text: string) {
checkPage(10);
pdf.setFontSize(9);
pdf.setFont("helvetica", "normal");
const lines = pdf.splitTextToSize(pdfSafeText(text), cw - 4);
pdf.text(lines, m + 2, y);
y += lines.length * 4.5 + 2;
}
function quoteBlock(text: string) {
const lines = pdf.splitTextToSize(`"${pdfSafeText(text)}"`, cw - 20);
const blockHeight = lines.length * 4.5 + 8;
checkPage(blockHeight + 4);
pdf.setFillColor(248, 250, 252);
pdf.roundedRect(m + 3, y - 1, cw - 6, blockHeight, 2, 2, "F");
pdf.setDrawColor(203, 213, 225);
pdf.line(m + 7, y + 1, m + 7, y + blockHeight - 3);
pdf.setFontSize(9);
pdf.setFont("helvetica", "italic");
pdf.text(lines, m + 12, y + 5);
y += blockHeight + 3;
}
pdf.setFillColor(13, 148, 136);
pdf.rect(0, 0, pw, 35, "F");
pdf.setTextColor(255, 255, 255);
pdf.setFontSize(18);
pdf.setFont("helvetica", "bold");
pdf.text("RAPORT ANALIZA didi", m, 16);
pdf.setFontSize(10);
pdf.setFont("helvetica", "normal");
const serviceLabel = pdfSafeText(
`${SERVICE_LABELS[selected.component] || selected.component} - ${MEDIA_LABELS[selected.media] || selected.media}`,
);
pdf.text(serviceLabel, m, 24);
pdf.text(pdfSafeText(`Data: ${new Date().toLocaleDateString("ro-RO")} | Factura: ${selected.invoice}`), m, 30);
y = 45;
pdf.setTextColor(51, 51, 51);
section("Scoruri principale");
if (typeof result.risk_score === "number") {
kv("Scor de risc", `${result.risk_score}/100 (${String(result.risk_level || result.risk_category || "")})`);
}
if (typeof result.confidence === "number") {
kv("Nivel incredere", `${result.confidence}%`);
}
if (result.risk_category) {
kv("Categorie", String(result.risk_category));
}
y += 3;
const explanation =
(selected.locale === "en" ? verdict.explanation_en : verdict.explanation_ro) ||
verdict.explanation_ro ||
verdict.explanation_en ||
result.conclusion ||
result.explanation ||
result.conclusion_explanation;
if (explanation) {
section("Concluzie");
para(String(explanation));
if (verdict.manipulation_level) kv("Nivel manipulare", String(verdict.manipulation_level));
if (verdict.recommended_action) kv("Actiune recomandata", String(verdict.recommended_action));
}
const techniques = result.techniques as Record<string, unknown> | undefined;
if (techniques) {
section(`Tehnici de manipulare (${String(techniques.techniques_count || 0)} detectate)`);
const detected = Array.isArray(techniques.techniques_detected)
? (techniques.techniques_detected as Record<string, unknown>[])
: [];
if (detected.length === 0) {
para("Nicio tehnica de manipulare detectata.");
} else {
for (const item of detected) {
checkPage(15);
pdf.setFontSize(9);
pdf.setFont("helvetica", "bold");
const name = pdfSafeText(formatAnalysisLabel(String(item.name || item.technique || "Tehnica")));
const severity = item.severity !== undefined ? ` [Severitate: ${String(item.severity)}/100]` : "";
pdf.text(pdfSafeText(`- ${name}${severity}`), m + 2, y);
y += 5;
if (item.evidence || item.description) {
quoteBlock(String(item.evidence || item.description));
}
}
}
}
const claims = result.claims as Record<string, unknown> | undefined;
if (claims && Number(claims.claims_found || 0) > 0) {
section(`Verificare afirmatii (${String(claims.claims_found)})`);
const rows = Array.isArray(claims.claims) ? (claims.claims as Record<string, unknown>[]) : [];
for (const claim of rows) {
checkPage(12);
pdf.setFontSize(9);
pdf.setFont("helvetica", "bold");
pdf.text(pdfSafeText(`[${String(claim.verdict || "N/A")}]`), m + 2, y);
pdf.setFont("helvetica", "normal");
const lines = pdf.splitTextToSize(pdfSafeText(String(claim.claim || claim.text || "")), cw - 30);
pdf.text(lines, m + 20, y);
y += Math.max(6, lines.length * 4.5) + 2;
}
}
const aiTampered = result.ai_tampered as Record<string, unknown> | undefined;
if (aiTampered) {
section("Detectie continut AI");
kv("Probabilitate AI", `${String(aiTampered.ai_probability || 0)}%`);
kv("Verdict", String(aiTampered.verdict || "-"));
}
const domain = result.domain as Record<string, unknown> | undefined;
if (domain) {
section("Evaluare sursa / domeniu");
kv("Scor credibilitate", `${String(domain.credibility_score || 0)}/100`);
kv("Categorie", String(domain.category || "-"));
}
if (verdict.virality_score !== undefined) {
section("Potential de viralitate");
kv("Scor viralitate", `${String(verdict.virality_score)}/100 (${String(verdict.virality_level || "")})`);
}
section("Metadate tehnice");
if (result.session_id) kv("Session ID", String(result.session_id));
if (Array.isArray(result.components_run)) kv("Module rulate", result.components_run.join(", "));
if (typeof result.total_duration_ms === "number") kv("Durata totala", `${(result.total_duration_ms / 1000).toFixed(1)} secunde`);
const pages = pdf.getNumberOfPages();
for (let i = 1; i <= pages; i++) {
pdf.setPage(i);
pdf.setFontSize(8);
pdf.setTextColor(156, 163, 175);
pdf.text(`Raport generat automat de platforma didi | Pagina ${i}/${pages}`, pw / 2, 287, { align: "center" });
}
return pdf.output("arraybuffer");
}
export function buildAnalysisReportFileName(selected: AnalysisMetadata) {
return `didi_raport_${selected.component}_${selected.media}_${new Date().toISOString().split("T")[0]}.pdf`;
}

View file

@ -0,0 +1,123 @@
import { buildAnalysisReportFileName, buildAnalysisReportPdf } from "@/lib/analysis-report-pdf";
const ERPNEXT_URL = process.env.ERPNEXT_API_URL || "http://localhost:8080";
const API_KEY = process.env.ERPNEXT_API_KEY || "";
const API_SECRET = process.env.ERPNEXT_API_SECRET || "";
type PersistArgs = {
customerName: string;
invoiceName: string;
sessionId: string;
component: string;
media: string;
locale?: "ro" | "en";
status?: string;
result: Record<string, unknown>;
};
function toErpDatetime(date: Date) {
return date.toISOString().slice(0, 19).replace("T", " ");
}
async function erpFetch<T>(endpoint: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${ERPNEXT_URL}${endpoint}`, {
...options,
headers: {
Authorization: `token ${API_KEY}:${API_SECRET}`,
...(options?.body instanceof FormData ? {} : { "Content-Type": "application/json" }),
...options?.headers,
},
});
if (!res.ok) {
throw new Error(`ERPNext API error: ${res.status} ${res.statusText}`);
}
return res.json();
}
export async function persistAnalysisReport({
customerName,
invoiceName,
sessionId,
component,
media,
locale,
status = "Completed",
result,
}: PersistArgs) {
const existing = await erpFetch<{ data: Array<{ name: string; pdf_file?: string | null }> }>(
`/api/resource/Analysis%20Report?filters=[["analysis_session_id","=","${encodeURIComponent(sessionId)}"]]&fields=["name","pdf_file"]&limit_page_length=1`,
).catch(() => ({ data: [] }));
const generatedAt = toErpDatetime(new Date());
let reportName = existing.data[0]?.name;
if (!reportName) {
const created = await erpFetch<{ data: { name: string } }>("/api/resource/Analysis%20Report", {
method: "POST",
body: JSON.stringify({
customer: customerName,
// credit-based (subscription) runs have no Sales Invoice behind them
sales_invoice: invoiceName === "subscription" ? "" : invoiceName,
analysis_session_id: sessionId,
component,
media_type: media,
report_title: `${component} - ${media} - ${invoiceName === "subscription" ? "abonament" : invoiceName}`,
status,
generated_at: generatedAt,
result_json: JSON.stringify(result, null, 2),
}),
});
reportName = created.data.name;
} else {
await erpFetch(`/api/resource/Analysis%20Report/${encodeURIComponent(reportName)}`, {
method: "PUT",
body: JSON.stringify({
status,
generated_at: generatedAt,
result_json: JSON.stringify(result, null, 2),
}),
});
}
const pdf = buildAnalysisReportPdf(result, {
invoice: invoiceName,
component,
media,
locale,
});
const fileName = buildAnalysisReportFileName({
invoice: invoiceName,
component,
media,
});
const formData = new FormData();
formData.append("doctype", "Analysis Report");
formData.append("docname", reportName);
formData.append("fieldname", "pdf_file");
formData.append("is_private", "1");
formData.append("folder", "Home/Attachments");
formData.append("file", new Blob([pdf], { type: "application/pdf" }), fileName);
const uploaded = await erpFetch<{ message?: { file_url?: string } }>(
"/api/method/upload_file",
{
method: "POST",
body: formData,
},
);
const fileUrl = uploaded.message?.file_url;
if (fileUrl) {
await erpFetch(`/api/resource/Analysis%20Report/${encodeURIComponent(reportName)}`, {
method: "PUT",
body: JSON.stringify({ pdf_file: fileUrl }),
});
}
return {
name: reportName,
pdfFile: fileUrl || null,
};
}

78
website/src/lib/api.ts Normal file
View file

@ -0,0 +1,78 @@
/**
* Client-side API helper.
* Calls go through /api/erp/... proxy (no CORS issues, credentials stay server-side).
*/
export async function apiFetch<T>(endpoint: string, options?: RequestInit): Promise<T> {
const res = await fetch(`/api/erp${endpoint}`, options);
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
// ─── Dashboard data fetchers ───
export async function fetchDashboardOverview(customerId: string) {
const [subRes, invRes] = await Promise.all([
apiFetch<{ data: Record<string, unknown>[] }>(
`/resource/Subscription?filters=[["party","=","${customerId}"]]&fields=["name","plan","status","current_invoice_start","current_invoice_end"]&order_by=creation desc&limit_page_length=1`
),
apiFetch<{ data: Record<string, unknown>[] }>(
`/resource/Sales%20Invoice?filters=[["customer","=","${customerId}"]]&fields=["name","posting_date","grand_total","currency","status"]&order_by=posting_date desc&limit_page_length=5`
),
]);
return {
subscription: subRes.data[0] || null,
invoices: invRes.data,
};
}
export async function fetchInvoices(customerId: string) {
const res = await apiFetch<{ data: Record<string, unknown>[] }>(
`/resource/Sales%20Invoice?filters=[["customer","=","${customerId}"]]&fields=["name","posting_date","grand_total","currency","status"]&order_by=posting_date desc`
);
return res.data;
}
export async function fetchSubscriptionPlans() {
const res = await apiFetch<{ data: Record<string, unknown>[] }>(
`/resource/Subscription%20Plan?fields=["name","plan_name","item","cost","currency","billing_interval","billing_interval_count"]&order_by=cost asc`
);
return res.data;
}
export async function fetchCustomer(customerId: string) {
const res = await apiFetch<{ data: Record<string, unknown> }>(
`/resource/Customer/${encodeURIComponent(customerId)}`
);
return res.data;
}
export async function updateCustomer(customerId: string, data: Record<string, unknown>) {
const res = await apiFetch<{ data: Record<string, unknown> }>(
`/resource/Customer/${encodeURIComponent(customerId)}`,
{ method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }
);
return res.data;
}
export async function submitLead(data: {
lead_name: string;
email_id: string;
notes?: string;
source_form?: string;
page_origin?: string;
}) {
return apiFetch<{ data: Record<string, unknown> }>("/resource/Lead", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
lead_name: data.lead_name,
email_id: data.email_id,
source: "Website - Contact Form",
notes: data.notes ? [{ note: data.notes }] : [],
source_form: data.source_form,
page_origin: data.page_origin,
}),
});
}

158
website/src/lib/auth.ts Normal file
View file

@ -0,0 +1,158 @@
import NextAuth from "next-auth";
import Keycloak from "next-auth/providers/keycloak";
import { getCustomerByKeycloakId, ensureErpCustomer } from "@/lib/erpnext";
import { registerDidiUser } from "@/lib/didi-backend";
/**
* Refresh an expired Keycloak access token using the stored refresh token.
* Returns the token with fresh access_token/expires_at, or flags an error so
* the UI can force a re-login when the refresh token itself is expired.
*/
async function refreshAccessToken(token: Record<string, unknown>) {
try {
const res = await fetch(
`${process.env.AUTH_KEYCLOAK_ISSUER}/protocol/openid-connect/token`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: process.env.AUTH_KEYCLOAK_ID!,
client_secret: process.env.AUTH_KEYCLOAK_SECRET!,
refresh_token: token.refreshToken as string,
}),
}
);
const refreshed = await res.json();
if (!res.ok) throw refreshed;
return {
...token,
accessToken: refreshed.access_token,
expiresAt: Math.floor(Date.now() / 1000) + (refreshed.expires_in as number),
// Keycloak rotates refresh tokens — keep the new one, fall back to old
refreshToken: refreshed.refresh_token ?? token.refreshToken,
error: undefined,
};
} catch {
return { ...token, error: "RefreshAccessTokenError" };
}
}
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Keycloak({
clientId: process.env.AUTH_KEYCLOAK_ID!,
clientSecret: process.env.AUTH_KEYCLOAK_SECRET!,
issuer: process.env.AUTH_KEYCLOAK_ISSUER!,
}),
],
useSecureCookies: false,
trustHost: true,
callbacks: {
async jwt({ token, account, profile }) {
// On first login, save Keycloak data to JWT
if (account) {
token.accessToken = account.access_token;
token.refreshToken = account.refresh_token;
token.idToken = account.id_token;
token.expiresAt = account.expires_at;
token.keycloakId = profile?.sub;
// Keycloak realm roles
const realmAccess = (profile as Record<string, unknown>)?.realm_access as
| { roles?: string[] }
| undefined;
token.roles = realmAccess?.roles || [];
// Provision the user in the DiDi backend (201 first time, 409 after —
// both fine); login must not fail if the backend is unreachable.
if (account.access_token) {
await registerDidiUser(account.access_token);
}
// Make sure the ERP Customer exists from the first login (not only after
// the first purchase) so /api/customer/me, subscription and analyses
// pages work for free-tier users too. Best-effort: never block login.
if (profile?.sub) {
await ensureErpCustomer({
keycloakId: profile.sub,
email: (profile.email as string) || "",
name: (profile.name as string) || "",
}).catch((err) => console.error("[auth] ERP customer provisioning failed:", err));
}
return token;
}
// Subsequent requests: return the token if it is still valid (60s buffer),
// otherwise refresh it against Keycloak so the dashboard keeps working.
const expiresAt = token.expiresAt as number | undefined;
if (expiresAt && Date.now() / 1000 < expiresAt - 60) {
return token;
}
if (!token.refreshToken) return token;
return await refreshAccessToken(token as Record<string, unknown>);
},
async session({ session, token }) {
// Expose useful data to client
session.user.id = token.keycloakId as string;
(session as unknown as Record<string, unknown>).accessToken = token.accessToken;
(session as unknown as Record<string, unknown>).roles = token.roles;
(session as unknown as Record<string, unknown>).error = token.error;
return session;
},
},
events: {
// Logout federat — termina si sesiunea SSO din Keycloak (backchannel cu
// refresh_token, clientul e confidential), altfel la urmatorul
// "Conecteaza-te" te reconecteaza automat fara sa mai ceara parola.
async signOut(message) {
const refreshToken =
"token" in message
? ((message.token as { refreshToken?: string })?.refreshToken ?? undefined)
: undefined;
if (!refreshToken) return;
try {
await fetch(
`${process.env.AUTH_KEYCLOAK_ISSUER}/protocol/openid-connect/logout`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: process.env.AUTH_KEYCLOAK_ID!,
client_secret: process.env.AUTH_KEYCLOAK_SECRET!,
refresh_token: refreshToken,
}),
}
);
} catch {
// nu bloca logout-ul local daca Keycloak nu raspunde
}
},
},
pages: {
signIn: "/login",
},
});
/**
* Helper to get the current user for server components.
* Returns null if not authenticated.
*/
export async function getCurrentUser() {
const session = await auth();
if (!session?.user) return null;
const keycloakId = session.user.id || "";
// Resolve ERPNext customer by Keycloak UUID (didi_user_id field)
const erpCustomerName = keycloakId
? await getCustomerByKeycloakId(keycloakId)
: null;
return {
id: keycloakId,
name: session.user.name || "",
email: session.user.email || "",
image: session.user.image,
roles: ((session as unknown as Record<string, unknown>).roles as string[]) || [],
accessToken: (session as unknown as Record<string, unknown>).accessToken as string,
customerId: erpCustomerName || "",
};
}

View file

@ -0,0 +1,173 @@
/**
* DiDi backend integration layer (framework API + M2M allocation).
*
* Implements the integration contracts validated against the live platform
* (see ~/didi-integrare-pack/README.md):
* - user provisioning: POST /api/auth/register at first login (201/409 = ok)
* - package allocation after payment: 3-step M2M flow on realm didi-admins
* (client_credentials token -> resolve user by email, field `id` ->
* PUT subscription with ABSOLUTE creditsRemained = current + package)
*
* All endpoints come from env so demo (mock :4000) and real platform swap
* without code changes.
*/
const FRAMEWORK_URL = process.env.DIDI_FRAMEWORK_URL || "http://didi-framework:3005";
const ADMIN_TOKEN_URL = process.env.DIDI_ADMIN_TOKEN_URL || "";
const ADMIN_CLIENT_ID = process.env.DIDI_ADMIN_CLIENT_ID || "";
const ADMIN_CLIENT_SECRET = process.env.DIDI_ADMIN_CLIENT_SECRET || "";
// Credits per one-time product, aligned with the platform catalog
// (/api/subscriptions/one-time-products): text/url=1, image=2, audio=3, video=5
const MEDIA_CREDITS: Record<string, number> = {
TEXT: 1,
URL: 1,
IMAGE: 2,
AUDIO: 3,
VIDEO: 5,
};
export function creditsForMedia(mediaType: string | undefined): number {
return MEDIA_CREDITS[(mediaType || "").toUpperCase()] ?? 1;
}
/**
* Remaining credits of the logged-in user (framework GET /api/auth/credits).
* Returns null when the backend is unreachable callers decide how strict to be.
*/
export async function getDidiCredits(accessToken: string): Promise<number | null> {
try {
const res = await fetch(`${FRAMEWORK_URL}/api/auth/credits`, {
headers: { Authorization: `Bearer ${accessToken}` },
cache: "no-store",
});
if (!res.ok) return null;
const body = await res.json();
const d = (body?.data ?? body) as Record<string, unknown>;
const raw = d.creditsRemained ?? d.credits_remained ?? d.creditsRemaining ?? d.remaining ?? d.credits;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
} catch {
return null;
}
}
/**
* Provision the user in the DiDi backend at first login.
* 201 = created, 409 = already exists (idempotent) both are success.
*/
export async function registerDidiUser(accessToken: string): Promise<"created" | "exists" | "failed"> {
try {
const res = await fetch(`${FRAMEWORK_URL}/api/auth/register`, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
if (res.status === 200 || res.status === 201) {
console.log("[DiDi Backend] register: user provisioned (201)");
return "created";
}
if (res.status === 409) return "exists";
console.error("[DiDi Backend] register failed:", res.status, (await res.text()).slice(0, 200));
return "failed";
} catch (err) {
console.error("[DiDi Backend] register error:", err);
return "failed";
}
}
async function getM2MToken(): Promise<string> {
const res = await fetch(ADMIN_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: ADMIN_CLIENT_ID,
client_secret: ADMIN_CLIENT_SECRET,
}),
});
const data = await res.json();
if (!res.ok || !data.access_token) {
throw new Error(`M2M token failed: ${res.status} ${JSON.stringify(data).slice(0, 200)}`);
}
return data.access_token as string;
}
type BackendUser = {
id: number;
creditsRemained: number;
subscriptionPlanId?: number;
};
async function resolveBackendUser(token: string, email: string): Promise<BackendUser | null> {
const res = await fetch(
`${FRAMEWORK_URL}/api/admin/users?search=${encodeURIComponent(email)}`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!res.ok) {
throw new Error(`admin/users search failed: ${res.status}`);
}
const body = await res.json();
const list = Array.isArray(body?.data) ? body.data : [];
// Trap #2 from the integration contracts: the field is `id`, not internetUserId
return list[0] ?? null;
}
export type AllocationResult = {
allocated: boolean;
userId?: number;
before?: number;
after?: number;
message?: string;
};
/**
* Allocate purchased credits (and optionally switch the plan) in the DiDi
* backend after a confirmed payment. Balance is ABSOLUTE per the contract:
* we read the current balance first, then PUT current + purchased.
*/
export async function allocateDidiPackage(opts: {
email: string;
credits: number;
planId?: number;
}): Promise<AllocationResult> {
try {
if (!ADMIN_TOKEN_URL || !ADMIN_CLIENT_ID) {
return { allocated: false, message: "M2M credentials not configured" };
}
const token = await getM2MToken();
const user = await resolveBackendUser(token, opts.email);
if (!user) {
return { allocated: false, message: `backend user not found for ${opts.email}` };
}
const before = Number(user.creditsRemained ?? 0);
const after = before + opts.credits;
const planId = opts.planId ?? user.subscriptionPlanId ?? 1;
const res = await fetch(`${FRAMEWORK_URL}/api/admin/users/${user.id}/subscription`, {
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ planId, creditsRemained: after }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok || body?.success === false) {
throw new Error(`allocation PUT failed: ${res.status} ${JSON.stringify(body).slice(0, 200)}`);
}
console.log(
`[DiDi Backend] allocated +${opts.credits} credits for ${opts.email}: ${before} -> ${after} (planId=${planId})`,
);
return { allocated: true, userId: user.id, before, after, message: body?.message };
} catch (err) {
console.error("[DiDi Backend] allocation error:", err);
return { allocated: false, message: String(err) };
}
}

218
website/src/lib/erpnext.ts Normal file
View file

@ -0,0 +1,218 @@
const ERPNEXT_URL = process.env.ERPNEXT_API_URL || "http://localhost:8080";
const API_KEY = process.env.ERPNEXT_API_KEY || "";
const API_SECRET = process.env.ERPNEXT_API_SECRET || "";
async function erpFetch<T>(
endpoint: string,
options?: RequestInit & { revalidate?: number }
): Promise<T> {
const { revalidate, ...fetchOptions } = options || {};
const res = await fetch(`${ERPNEXT_URL}${endpoint}`, {
...fetchOptions,
headers: {
Authorization: `token ${API_KEY}:${API_SECRET}`,
"Content-Type": "application/json",
...fetchOptions?.headers,
},
next: revalidate !== undefined ? { revalidate } : undefined,
});
if (!res.ok) {
throw new Error(`ERPNext API error: ${res.status} ${res.statusText}`);
}
return res.json();
}
// ─── Customer provisioning ───
/**
* Find or create the ERPNext Customer for a Keycloak user.
* Lookup order: didi_user_id email_id (then the Keycloak id is attached) create.
* Deliberately NO lookup by display name: two people with the same name (or a
* test account) must never share a Customer record.
*/
export async function ensureErpCustomer(user: {
keycloakId: string;
email: string;
name?: string;
}): Promise<string> {
const { keycloakId, email } = user;
if (!keycloakId) throw new Error("ensureErpCustomer: keycloakId required");
const byId = await erpFetch<{ data: Array<{ name: string }> }>(
`/api/resource/Customer?filters=[["didi_user_id","=","${encodeURIComponent(keycloakId)}"]]&fields=["name"]&limit_page_length=1`
).catch(() => ({ data: [] }));
if (byId.data[0]?.name) return byId.data[0].name;
if (email) {
const byEmail = await erpFetch<{ data: Array<{ name: string }> }>(
`/api/resource/Customer?filters=[["email_id","=","${encodeURIComponent(email)}"]]&fields=["name"]&limit_page_length=1`
).catch(() => ({ data: [] }));
const existing = byEmail.data[0]?.name;
if (existing) {
await erpFetch(`/api/resource/Customer/${encodeURIComponent(existing)}`, {
method: "PUT",
body: JSON.stringify({ didi_user_id: keycloakId }),
}).catch(() => null);
return existing;
}
}
// customer_group/territory omitted on purpose — this ERP setup has none defined
const created = await erpFetch<{ data: { name: string } }>("/api/resource/Customer", {
method: "POST",
body: JSON.stringify({
customer_name: user.name || email.split("@")[0] || keycloakId,
customer_type: "Individual",
didi_user_id: keycloakId,
email_id: email,
}),
});
return created.data.name;
}
// ─── Website Content (CMS) ───
export async function getWebsiteContent(pageSlug: string) {
const res = await erpFetch<{ data: Array<Record<string, string>> }>(
`/api/resource/Website%20Content?filters=[["page_slug","=","${pageSlug}"],["is_active","=",1]]&fields=["section_key","content_ro","content_en","image","extra_data","display_order"]&order_by=display_order asc`,
{ revalidate: 60 }
);
return res.data;
}
// ─── Subscription Plans ───
export interface SubscriptionPlan {
name: string;
plan_name: string;
item: string;
cost: number;
currency: string;
billing_interval: string;
billing_interval_count: number;
}
export async function getSubscriptionPlans() {
const res = await erpFetch<{ data: SubscriptionPlan[] }>(
`/api/resource/Subscription%20Plan?fields=["name","plan_name","item","cost","currency","billing_interval","billing_interval_count"]&order_by=cost asc`,
{ revalidate: 300 }
);
return res.data;
}
// ─── Leads ───
export async function createLead(data: {
lead_name: string;
email_id: string;
source?: string;
notes?: string;
source_form?: string;
page_origin?: string;
}) {
return erpFetch<{ data: Record<string, string> }>("/api/resource/Lead", {
method: "POST",
body: JSON.stringify({
lead_name: data.lead_name,
email_id: data.email_id,
source: data.source || "Website - Contact Form",
notes: data.notes ? [{ note: data.notes }] : [],
source_form: data.source_form,
page_origin: data.page_origin,
}),
});
}
// ─── Customer ───
export async function getCustomerByKeycloakId(keycloakId: string): Promise<string | null> {
const res = await erpFetch<{ data: Array<{ name: string }> }>(
`/api/resource/Customer?filters=[["didi_user_id","=","${encodeURIComponent(keycloakId)}"]]&fields=["name"]&limit_page_length=1`
).catch(() => ({ data: [] }));
return res.data.length > 0 ? res.data[0].name : null;
}
export async function getCustomer(customerId: string) {
return erpFetch<{ data: Record<string, unknown> }>(
`/api/resource/Customer/${encodeURIComponent(customerId)}`
);
}
export async function updateCustomer(
customerId: string,
data: Record<string, unknown>
) {
return erpFetch<{ data: Record<string, unknown> }>(
`/api/resource/Customer/${encodeURIComponent(customerId)}`,
{ method: "PUT", body: JSON.stringify(data) }
);
}
// ─── Invoices ───
export async function getInvoices(customerId: string) {
return erpFetch<{ data: Array<Record<string, unknown>> }>(
`/api/resource/Sales%20Invoice?filters=[["customer","=","${encodeURIComponent(customerId)}"],["docstatus","=",1]]&fields=["name","posting_date","grand_total","currency","status"]&order_by=posting_date desc`
);
}
export async function getInvoiceAnalysisStats(customerId: string) {
const res = await erpFetch<{ data: Array<Record<string, unknown>> }>(
`/api/resource/Sales%20Invoice?filters=[["customer","=","${encodeURIComponent(customerId)}"]]&fields=["name","status","docstatus","outstanding_amount","analysis_consumed"]&order_by=posting_date desc`
);
const confirmed = res.data.filter((inv) => String(inv.status) !== "Draft").length;
const available = res.data.filter((inv) => {
const status = String(inv.status || "");
const docstatus = Number(inv.docstatus || 0);
const outstanding = Number(inv.outstanding_amount || 0);
const consumed = Number(inv.analysis_consumed || 0);
return docstatus === 1 && consumed === 0 && (status === "Paid" || outstanding === 0);
}).length;
return { confirmed, available };
}
export async function getInvoicePdf(invoiceName: string) {
const res = await fetch(
`${ERPNEXT_URL}/api/method/frappe.utils.print_format.download_pdf?doctype=Sales%20Invoice&name=${encodeURIComponent(invoiceName)}&format=DiDi%20Invoice`,
{
headers: { Authorization: `token ${API_KEY}:${API_SECRET}` },
}
);
if (!res.ok) throw new Error("Failed to fetch invoice PDF");
return res.arrayBuffer();
}
// ─── Subscription ───
export async function getCustomerSubscription(customerId: string) {
const res = await erpFetch<{ data: Array<Record<string, unknown>> }>(
`/api/resource/Subscription?filters=[["party","=","${encodeURIComponent(customerId)}"]]&fields=["name","plan","status","current_invoice_start","current_invoice_end"]&order_by=creation desc&limit_page_length=1`
);
return res.data[0] || null;
}
// ─── Service Agreement ───
export async function createServiceAgreement(data: {
customer: string;
plan: string;
terms_version: string;
client_ip: string;
agreement_html: string;
}) {
return erpFetch<{ data: Record<string, unknown> }>(
"/api/resource/Service%20Agreement",
{
method: "POST",
body: JSON.stringify({
...data,
status: "Accepted",
acceptance_date: new Date().toISOString(),
}),
}
);
}

View file

@ -0,0 +1,470 @@
import type Stripe from "stripe";
import { PRICE_MAP, stripe } from "@/lib/stripe";
import { allocateDidiPackage, creditsForMedia } from "@/lib/didi-backend";
import { ensureErpCustomer } from "@/lib/erpnext";
const ERPNEXT_URL = process.env.ERPNEXT_API_URL || "http://localhost:8080";
const API_KEY = process.env.ERPNEXT_API_KEY || "";
const API_SECRET = process.env.ERPNEXT_API_SECRET || "";
type FulfillmentResult =
| { status: "missing_session" | "unpaid" | "invalid" }
| { status: "already_processed"; invoiceName?: string | null }
| { status: "fulfilled"; invoiceName: string; paymentEntryName: string; serviceAgreementName?: string | null };
async function erpFetch<T>(endpoint: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${ERPNEXT_URL}${endpoint}`, {
...options,
headers: {
Authorization: `token ${API_KEY}:${API_SECRET}`,
"Content-Type": "application/json",
...options?.headers,
},
});
if (!res.ok) {
const errBody = await res.text();
throw new Error(`ERPNext API ${res.status} on ${endpoint}: ${errBody.substring(0, 300)}`);
}
return res.json();
}
function extractInvoiceNameFromLog(rawWebhookData: string | null | undefined) {
if (!rawWebhookData) return null;
try {
const parsed = JSON.parse(rawWebhookData) as Record<string, unknown>;
const invoiceName = parsed.invoice_name;
return typeof invoiceName === "string" ? invoiceName : null;
} catch {
return null;
}
}
async function ensureItem(itemCode: string, itemName: string, rate: number) {
const existing = await erpFetch<{ data: Record<string, unknown> }>(
`/api/resource/Item/${encodeURIComponent(itemCode)}`,
).catch(() => null);
if (existing?.data) return;
await erpFetch("/api/resource/Item", {
method: "POST",
body: JSON.stringify({
item_code: itemCode,
item_name: itemName,
item_group: await resolveItemGroup(),
stock_uom: "Nos",
is_stock_item: 0,
// no standard_rate: it auto-creates an "Item Price", which the website API
// user is not allowed to insert (403). The price lives on the invoice line.
description: `${itemName} (${rate} RON)`,
}),
});
}
/**
* Item Group for DiDi items: reuse the group of an existing DIDI-* item, else
* "DiDi Services" (created under the ROOT group, whose name depends on the ERP
* language never hardcode "All Item Groups"), else any non-group leaf.
*/
async function resolveItemGroup(): Promise<string> {
const sibling = await erpFetch<{ data: Array<{ item_group: string }> }>(
`/api/resource/Item?filters=[["item_code","like","DIDI-%"]]&fields=["item_group"]&limit_page_length=1`,
).catch(() => ({ data: [] }));
if (sibling.data[0]?.item_group) return sibling.data[0].item_group;
const existing = await erpFetch<{ data: { name: string } }>("/api/resource/Item%20Group/DiDi%20Services").catch(() => null);
if (existing?.data?.name) return existing.data.name;
const root = await erpFetch<{ data: Array<{ name: string }> }>(
`/api/resource/Item%20Group?filters=[["is_group","=",1],["parent_item_group","in",["",null]]]&fields=["name"]&limit_page_length=1`,
).catch(() => ({ data: [] }));
if (root.data[0]?.name) {
const created = await erpFetch<{ data: { name: string } }>("/api/resource/Item%20Group", {
method: "POST",
body: JSON.stringify({ item_group_name: "DiDi Services", parent_item_group: root.data[0].name }),
}).catch(() => null);
if (created?.data?.name) return created.data.name;
}
const leaf = await erpFetch<{ data: Array<{ name: string }> }>(
`/api/resource/Item%20Group?filters=[["is_group","=",0]]&fields=["name"]&limit_page_length=1`,
).catch(() => ({ data: [] }));
if (leaf.data[0]?.name) return leaf.data[0].name;
throw new Error("No Item Group available in ERPNext for DiDi items");
}
/** Serialize async work per key inside this process (see finalizeStripeCheckout). */
const inflightByKey = new Map<string, Promise<unknown>>();
export function withProcessLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
const pending = inflightByKey.get(key) as Promise<T> | undefined;
if (pending) return pending;
const run = fn().finally(() => inflightByKey.delete(key));
inflightByKey.set(key, run);
return run;
}
async function getReceivingAccount(company: string) {
const bankAccounts = await erpFetch<{ data: Array<{ name: string }> }>(
`/api/resource/Account?filters=[["company","=","${encodeURIComponent(company)}"],["account_type","in",["Bank","Cash"]],["is_group","=",0]]&fields=["name"]&order_by=account_type desc&limit_page_length=1`,
);
return bankAccounts.data[0]?.name || "Cont Principal - TC";
}
async function createAndSubmitPaymentEntry(args: {
invoiceName: string;
customerName: string;
company: string;
currency: string;
receivableAccount: string;
amount: number;
dueDate: string;
referenceNo: string;
}) {
const paidTo = await getReceivingAccount(args.company);
const paymentEntry = await erpFetch<{ data: { name: string } }>("/api/resource/Payment%20Entry", {
method: "POST",
body: JSON.stringify({
payment_type: "Receive",
posting_date: new Date().toISOString().slice(0, 10),
reference_no: args.referenceNo,
reference_date: new Date().toISOString().slice(0, 10),
company: args.company,
party_type: "Customer",
party: args.customerName,
party_name: args.customerName,
paid_from: args.receivableAccount,
paid_to: paidTo,
paid_from_account_currency: args.currency,
paid_to_account_currency: args.currency,
paid_amount: args.amount,
received_amount: args.amount,
source_exchange_rate: 1,
target_exchange_rate: 1,
references: [
{
reference_doctype: "Sales Invoice",
reference_name: args.invoiceName,
allocated_amount: args.amount,
due_date: args.dueDate,
total_amount: args.amount,
outstanding_amount: args.amount,
},
],
}),
});
const fullPaymentEntry = await erpFetch<{ data: Record<string, unknown> }>(
`/api/resource/Payment%20Entry/${encodeURIComponent(paymentEntry.data.name)}`,
);
await erpFetch("/api/method/frappe.client.submit", {
method: "POST",
body: JSON.stringify({ doc: fullPaymentEntry.data }),
});
return paymentEntry.data.name;
}
async function createAndSubmitInvoice(args: {
customerName: string;
itemCode: string;
rate: number;
}) {
const invoice = await erpFetch<{ data: Record<string, unknown> }>("/api/resource/Sales%20Invoice", {
method: "POST",
body: JSON.stringify({
customer: args.customerName,
company: "TOP CLOSSERS SRL",
naming_series: "DIDI-INV-.YYYY.-.#####",
// Stripe charges in RON — pin the invoice currency so a customer with a
// different default currency doesn't produce a converted, partly-paid invoice
currency: "RON",
conversion_rate: 1,
items: [{ item_code: args.itemCode, qty: 1, rate: args.rate }],
// Stripe charges the gross amount, so VAT is included in the rate —
// otherwise the invoice total exceeds what was actually collected
taxes: [{
charge_type: "On Net Total",
account_head: "4427 - 4427 - TVA colectata - TC",
description: "TVA 21% (inclus)",
rate: 21,
included_in_print_rate: 1,
}],
}),
});
await erpFetch("/api/method/frappe.client.submit", {
method: "POST",
body: JSON.stringify({ doc: invoice.data }),
});
return invoice.data;
}
async function createServiceAgreement(args: {
customerName: string;
itemCode: string;
serviceLabel: string;
invoiceName: string;
}) {
const now = new Date().toISOString().slice(0, 19).replace("T", " ");
const result = await erpFetch<{ data: { name: string } }>("/api/resource/Service%20Agreement", {
method: "POST",
body: JSON.stringify({
customer: args.customerName,
plan: args.itemCode,
sales_invoice: args.invoiceName,
status: "Accepted",
acceptance_date: now,
terms_version: "2026-v1",
agreement_html: `<p>Acord de servicii pentru ${args.serviceLabel}. Generat automat la achizitia facturii ${args.invoiceName}.</p>`,
}),
});
return result.data.name;
}
function determineIamRole(itemCode: string): string {
if (itemCode.startsWith("DIDI-ENTERPRISE")) return "enterprise_tier";
if (itemCode.startsWith("DIDI-PAID")) return "paid_tier";
return "free_tier";
}
/**
* Only SUBSCRIPTIONS change the customer's plan/role. A one-time analysis
* purchase is pay-per-use: it must not mark the customer as a "paid" subscriber
* (that would show a plan that was never bought and later trigger the daily
* subscription-expiry downgrade + "your subscription expired" email).
*/
async function updateCustomerPlan(customerName: string, itemCode: string, stripeSubscriptionId: string) {
const role = determineIamRole(itemCode);
// Customer.active_plan is a Select ("", free, paid, enterprise) — derive it
// from the role instead of writing the raw item code (which fails validation)
const activePlan = role.replace("_tier", "");
await erpFetch<{ data: Record<string, unknown> }>(
`/api/resource/Customer/${encodeURIComponent(customerName)}`,
{
method: "PUT",
body: JSON.stringify({
iam_role: role,
active_plan: activePlan === "free" ? "" : activePlan,
plan_activation_date: new Date().toISOString().slice(0, 10),
stripe_subscription_id: stripeSubscriptionId,
}),
},
);
}
function buildItemDetails(checkoutSession: Stripe.Checkout.Session) {
const metadata = checkoutSession.metadata || {};
const isSubscription = checkoutSession.mode === "subscription";
let itemCode: string;
let serviceLabel: string;
if (isSubscription) {
const planKey = metadata.plan_key || "";
if (planKey.startsWith("enterprise")) itemCode = "DIDI-ENTERPRISE";
else if (planKey.startsWith("paid")) itemCode = "DIDI-PAID";
else itemCode = "DIDI-FREE";
serviceLabel = metadata.plan_label || "DiDi Subscription";
} else {
const serviceKey = `${metadata.component}-${metadata.media_type}`;
const service = PRICE_MAP[serviceKey];
itemCode = service
? `DIDI-${metadata.component?.toUpperCase()}-${metadata.media_type?.toUpperCase()}`
: "DIDI-SERVICE";
serviceLabel = metadata.service_label || "DiDi Service";
}
const rate = (checkoutSession.amount_total || 0) / 100;
return {
itemCode,
rate,
serviceLabel,
mediaType: metadata.media_type || "",
email: metadata.user_email || checkoutSession.customer_details?.email || "",
userName: metadata.user_name || checkoutSession.customer_details?.name || "",
keycloakId: metadata.keycloak_id || "",
isSubscription,
planKey: metadata.plan_key || "",
planInterval: metadata.plan_interval || "",
stripeSubscriptionId: typeof checkoutSession.subscription === "string"
? checkoutSession.subscription
: checkoutSession.subscription?.id || "",
};
}
/**
* Create + submit a paid Sales Invoice (invoice, payment entry, email) for an
* amount already collected by Stripe. Used for the first payment (checkout)
* and for every subscription renewal cycle (invoice.paid webhook).
*/
export async function createPaidErpInvoice(args: {
customerName: string;
itemCode: string;
serviceLabel: string;
rate: number;
referenceNo: string;
}): Promise<{ invoiceName: string; paymentEntryName: string }> {
await ensureItem(args.itemCode, args.serviceLabel, args.rate);
const invoice = await createAndSubmitInvoice({
customerName: args.customerName,
itemCode: args.itemCode,
rate: args.rate,
});
const invoiceName = String(invoice.name || "");
const roundedTotal = Number(invoice.rounded_total || invoice.grand_total || 0);
const paymentEntryName = await createAndSubmitPaymentEntry({
invoiceName,
customerName: args.customerName,
company: String(invoice.company || ""),
currency: String(invoice.currency || "RON"),
receivableAccount: String(invoice.debit_to || ""),
amount: roundedTotal,
dueDate: String(invoice.due_date || new Date().toISOString().slice(0, 10)),
referenceNo: args.referenceNo,
});
// Transactional email: payment confirmation + invoice PDF (ref CS II.5.2/II.5.3);
// SMTP is the ERPNext Email Account "DiDi Outgoing"
await erpFetch("/api/method/didi_custom.notifications.send_invoice_email", {
method: "POST",
body: JSON.stringify({ invoice_name: invoiceName }),
}).then((r) => {
console.log("[Stripe Fulfillment] Invoice email:", JSON.stringify(r).slice(0, 150));
}).catch((err) => {
console.error("[Stripe Fulfillment] Invoice email failed:", err);
});
return { invoiceName, paymentEntryName };
}
/**
* The Stripe webhook and the /checkout/success page both call fulfillment for
* the same session, usually within the same second. The "already processed"
* check alone is not enough (both read before either writes), which produced
* duplicate invoices/payments/credits. Serialize per session id in-process
* (single Next.js server) so the second caller waits and gets the same result.
*/
const inflightFulfillments = new Map<string, Promise<FulfillmentResult>>();
export async function finalizeStripeCheckout(sessionId: string): Promise<FulfillmentResult> {
if (!sessionId) return { status: "missing_session" };
const pending = inflightFulfillments.get(sessionId);
if (pending) return pending;
const run = runFulfillment(sessionId).finally(() => {
inflightFulfillments.delete(sessionId);
});
inflightFulfillments.set(sessionId, run);
return run;
}
async function runFulfillment(sessionId: string): Promise<FulfillmentResult> {
const checkoutSession = await stripe.checkout.sessions.retrieve(sessionId);
if (!checkoutSession || checkoutSession.payment_status !== "paid") {
return { status: "unpaid" };
}
const existingLog = await erpFetch<{ data: Array<{ raw_webhook_data?: string | null }> }>(
`/api/resource/Payment%20Log?filters=[["stripe_session_id","=","${encodeURIComponent(sessionId)}"]]&fields=["raw_webhook_data"]&limit_page_length=1`,
).catch(() => ({ data: [] }));
if (existingLog.data[0]) {
return {
status: "already_processed",
invoiceName: extractInvoiceNameFromLog(existingLog.data[0].raw_webhook_data),
};
}
const itemDetails = buildItemDetails(checkoutSession);
if (!itemDetails.email || !itemDetails.keycloakId) {
return { status: "invalid" };
}
const customerName = await ensureErpCustomer({
keycloakId: itemDetails.keycloakId,
email: itemDetails.email,
name: itemDetails.userName,
});
const { invoiceName, paymentEntryName } = await createPaidErpInvoice({
customerName,
itemCode: itemDetails.itemCode,
serviceLabel: itemDetails.serviceLabel,
rate: itemDetails.rate,
referenceNo: String(checkoutSession.payment_intent || checkoutSession.id),
});
await erpFetch("/api/resource/Payment%20Log", {
method: "POST",
body: JSON.stringify({
customer: customerName,
stripe_session_id: checkoutSession.id,
stripe_payment_intent_id: checkoutSession.payment_intent || "",
status: "Succeeded",
event_type: "checkout.session.completed",
amount: itemDetails.rate,
currency: "RON",
raw_webhook_data: JSON.stringify(
{
...checkoutSession,
invoice_name: invoiceName,
payment_entry_name: paymentEntryName,
},
null,
2,
),
}),
});
// Create Service Agreement (ref CS II.5.6)
const serviceAgreementName = await createServiceAgreement({
customerName,
itemCode: itemDetails.itemCode,
serviceLabel: itemDetails.serviceLabel,
invoiceName,
}).catch((err) => {
console.error("[Stripe Fulfillment] Service Agreement creation failed:", err);
return null;
});
// Update customer iam_role / active_plan — subscriptions only (pay-per-use
// purchases leave the plan untouched)
if (itemDetails.isSubscription) {
await updateCustomerPlan(customerName, itemDetails.itemCode, itemDetails.stripeSubscriptionId).catch((err) => {
console.error("[Stripe Fulfillment] Customer plan update failed:", err);
});
}
// Allocate the purchased package in the DiDi backend (M2M, absolute balance).
// One-time buys add the product's credits on the current plan; subscriptions
// switch the backend plan (paid/enterprise) via env-configurable plan ids.
const allocation = itemDetails.isSubscription
? await allocateDidiPackage({
email: itemDetails.email,
credits: 0,
planId: itemDetails.planKey.startsWith("enterprise")
? Number(process.env.DIDI_PLAN_ID_ENTERPRISE || 6)
: Number(process.env.DIDI_PLAN_ID_PAID || 3),
})
: await allocateDidiPackage({
email: itemDetails.email,
credits: creditsForMedia(itemDetails.mediaType),
});
if (!allocation.allocated) {
console.error("[Stripe Fulfillment] DiDi credit allocation FAILED:", allocation.message);
}
return {
status: "fulfilled",
invoiceName,
paymentEntryName,
serviceAgreementName,
};
}

60
website/src/lib/stripe.ts Normal file
View file

@ -0,0 +1,60 @@
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2026-03-25.dahlia",
});
/**
* One Stripe Customer per Keycloak user (metadata.keycloak_id). Checkout sessions
* must reference this customer instead of `customer_email`, otherwise Stripe
* creates a new Customer on every checkout and subscription management can
* pick the wrong one.
*/
export async function getOrCreateStripeCustomer(user: {
keycloakId: string;
email: string;
name?: string;
}): Promise<string> {
const found = await stripe.customers.search({
query: `metadata['keycloak_id']:'${user.keycloakId.replace(/'/g, "")}'`,
limit: 1,
});
if (found.data[0]) return found.data[0].id;
const created = await stripe.customers.create({
email: user.email || undefined,
name: user.name || undefined,
metadata: { keycloak_id: user.keycloakId },
});
return created.id;
}
/** Active (or trialing / past_due) Stripe subscription of a Keycloak user, if any. */
export async function findActiveSubscription(keycloakId: string): Promise<Stripe.Subscription | null> {
const res = await stripe.subscriptions.search({
query: `metadata['keycloak_id']:'${keycloakId.replace(/'/g, "")}' AND status:'active'`,
limit: 1,
});
return res.data[0] || null;
}
/**
* All DiDi one-time service price IDs from Stripe.
* These map to the products created in Stripe sandbox.
*/
export const PRICE_MAP: Record<string, { priceId: string; ron: number; label: string; component: string; mediaType: string }> = {
"techniques-text": { priceId: "price_1U8QIpRQMzLRJQdLm3DCojcP", ron: 50, label: "Techniques - Text", component: "techniques", mediaType: "text" },
"techniques-image": { priceId: "price_1U8QIpRQMzLRJQdLsbzpXSbh", ron: 75, label: "Techniques - Image", component: "techniques", mediaType: "image" },
"techniques-audio": { priceId: "price_1U8QIqRQMzLRJQdL1bqVuTO9", ron: 100, label: "Techniques - Audio", component: "techniques", mediaType: "audio" },
"techniques-video": { priceId: "price_1U8QIqRQMzLRJQdLDXuuHDkr", ron: 200, label: "Techniques - Video", component: "techniques", mediaType: "video" },
"ai_detection-text": { priceId: "price_1U8QIrRQMzLRJQdL868sHfYu", ron: 50, label: "AI Detection - Text", component: "ai_detection", mediaType: "text" },
"ai_detection-image":{ priceId: "price_1U8QIrRQMzLRJQdL6GP3Fgsc", ron: 75, label: "AI Detection - Image",component: "ai_detection", mediaType: "image" },
"ai_detection-audio":{ priceId: "price_1U8QIsRQMzLRJQdLKYu354GJ", ron: 100, label: "AI Detection - Audio",component: "ai_detection", mediaType: "audio" },
"ai_detection-video":{ priceId: "price_1U8QIsRQMzLRJQdLMFdVSkH0", ron: 200, label: "AI Detection - Video",component: "ai_detection", mediaType: "video" },
"claims-text": { priceId: "price_1U8QIsRQMzLRJQdLnkBTMG2G", ron: 50, label: "Claims - Text", component: "claims", mediaType: "text" },
"claims-image": { priceId: "price_1U8QItRQMzLRJQdLRIzM25Vy", ron: 75, label: "Claims - Image", component: "claims", mediaType: "image" },
"claims-audio": { priceId: "price_1U8QItRQMzLRJQdLicXnGXQo", ron: 75, label: "Claims - Audio", component: "claims", mediaType: "audio" },
"claims-video": { priceId: "price_1U8QIuRQMzLRJQdLNWjH7uiO", ron: 150, label: "Claims - Video", component: "claims", mediaType: "video" },
"source-text": { priceId: "price_1U8QIuRQMzLRJQdLXUT8yMoG", ron: 50, label: "Source Assessment - Text", component: "source", mediaType: "text" },
"source-url": { priceId: "price_1U8QIvRQMzLRJQdLGRpDwsBo", ron: 50, label: "Source Assessment - URL", component: "source", mediaType: "url" },
};

View file

@ -0,0 +1,5 @@
export { auth as middleware } from "@/lib/auth";
export const config = {
matcher: ["/dashboard/:path*"],
};

34
website/tsconfig.json Normal file
View file

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}