livrare website cu erp si crm
5
website/.dockerignore
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
.next
|
||||
.git
|
||||
*.log
|
||||
.env.local
|
||||
61
website/.env.example
Normal 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
|
|
@ -0,0 +1,6 @@
|
|||
node_modules/
|
||||
.next/
|
||||
*.log
|
||||
.DS_Store
|
||||
.env.production
|
||||
.env.local
|
||||
151
website/.gitlab-ci.yml
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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 "============================================="
|
||||
24
website/docker-compose.yml
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
31
website/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
website/postcss.config.mjs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
BIN
website/public/clossersLogo2.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
1
website/public/file.svg
Normal 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
|
|
@ -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
|
After Width: | Height: | Size: 20 KiB |
1
website/public/next.svg
Normal 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 |
BIN
website/public/pnrr-banner.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
website/public/pnrr-eu-nextgen.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
website/public/pnrr-logo-eu.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
website/public/pnrr-logo-guv.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
website/public/pnrr-logo-pnrr.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
1
website/public/vercel.svg
Normal 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 |
1
website/public/window.svg
Normal 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 |
176
website/scripts/archive/generate_docs.py
Normal 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!")
|
||||
3782
website/scripts/archive/generate_livrabile.py
Normal file
875
website/scripts/archive/generate_propunere.py
Normal 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}')
|
||||
7
website/src/app/(auth)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
website/src/app/(auth)/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
website/src/app/(auth)/register/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
website/src/app/(dashboard)/dashboard/achizitioneaza/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
website/src/app/(dashboard)/dashboard/analiza/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AnalyzaRedirect() {
|
||||
redirect("/dashboard/analize");
|
||||
}
|
||||
664
website/src/app/(dashboard)/dashboard/analize/page.tsx
Normal 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">“{String(technique.evidence)}”</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>
|
||||
);
|
||||
}
|
||||
229
website/src/app/(dashboard)/dashboard/checkout/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
130
website/src/app/(dashboard)/dashboard/credits/page.tsx
Normal 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> · 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>
|
||||
);
|
||||
}
|
||||
71
website/src/app/(dashboard)/dashboard/invoices/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
192
website/src/app/(dashboard)/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
202
website/src/app/(dashboard)/dashboard/profile/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
145
website/src/app/(dashboard)/dashboard/subscription/page.tsx
Normal 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;
|
||||
}
|
||||
53
website/src/app/(dashboard)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
website/src/app/(public)/about/page.tsx
Normal 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; }
|
||||
}
|
||||
47
website/src/app/(public)/contact/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
website/src/app/(public)/layout.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
219
website/src/app/(public)/page.tsx
Normal 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"} →
|
||||
</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"} →
|
||||
</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;
|
||||
}
|
||||
367
website/src/app/(public)/pricing/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
25
website/src/app/(public)/privacy/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
website/src/app/(public)/services/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
25
website/src/app/(public)/terms/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
website/src/app/api/analysis-report-pdf/route.ts
Normal 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"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
91
website/src/app/api/analysis-reports/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
400
website/src/app/api/analyze/route.ts
Normal 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 });
|
||||
}
|
||||
3
website/src/app/api/auth/[...nextauth]/route.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { handlers } from "@/lib/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
54
website/src/app/api/checkout/route.ts
Normal 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 });
|
||||
}
|
||||
64
website/src/app/api/credits/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
22
website/src/app/api/customer/me/route.ts
Normal 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);
|
||||
}
|
||||
147
website/src/app/api/erp/[...path]/route.ts
Normal 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");
|
||||
}
|
||||
163
website/src/app/api/gdpr/delete/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
163
website/src/app/api/gdpr/export/route.ts
Normal 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}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
56
website/src/app/api/invoice-pdf/route.ts
Normal 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"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
32
website/src/app/api/leads/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
113
website/src/app/api/subscribe/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
111
website/src/app/api/subscription/manage/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
282
website/src/app/api/webhooks/stripe/route.ts
Normal 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
|
After Width: | Height: | Size: 25 KiB |
36
website/src/app/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
52
website/src/app/layout.tsx
Normal 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
|
|
@ -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
|
|
@ -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`,
|
||||
};
|
||||
}
|
||||
13
website/src/app/sitemap.ts
Normal 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 },
|
||||
];
|
||||
}
|
||||
98
website/src/components/forms/ContactForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
website/src/components/layout/DashboardSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
website/src/components/layout/Footer.tsx
Normal 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">
|
||||
„Conținutul acestui material nu reprezintă în mod obligatoriu poziția
|
||||
oficială a Uniunii Europene sau a Guvernului României”
|
||||
</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>© {new Date().getFullYear()} TOP CLOSSERS SRL. {t.footer.rights}</p>
|
||||
<p className="mt-1">CUI: 36193026 | Ploiesti, Romania</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
125
website/src/components/layout/Navbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
website/src/components/layout/PnrrBanner.tsx
Normal 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 să 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>
|
||||
);
|
||||
}
|
||||
11
website/src/components/providers/SessionProvider.tsx
Normal 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>;
|
||||
}
|
||||
107
website/src/components/ui/CookieConsent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
website/src/components/ui/SignOutButton.tsx
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
21
website/src/i18n/server.ts
Normal 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) };
|
||||
}
|
||||
47
website/src/i18n/useLocale.ts
Normal 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 };
|
||||
}
|
||||
13
website/src/lib/analysis-display.ts
Normal 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(" / ");
|
||||
}
|
||||
217
website/src/lib/analysis-report-pdf.ts
Normal 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`;
|
||||
}
|
||||
123
website/src/lib/analysis-report-store.ts
Normal 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
|
|
@ -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
|
|
@ -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 || "",
|
||||
};
|
||||
}
|
||||
173
website/src/lib/didi-backend.ts
Normal 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
|
|
@ -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(),
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
470
website/src/lib/stripe-fulfillment.ts
Normal 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
|
|
@ -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" },
|
||||
};
|
||||
5
website/src/middleware.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export { auth as middleware } from "@/lib/auth";
|
||||
|
||||
export const config = {
|
||||
matcher: ["/dashboard/:path*"],
|
||||
};
|
||||
34
website/tsconfig.json
Normal 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"]
|
||||
}
|
||||