Omnichat

Contractul pentru magazine proprii

Descarcă (.md)

Contractul pentru magazine proprii

Ghid pentru dezvoltatorul magazinului. Dacă site-ul nu e WooCommerce sau Shopify, asistentul se conectează prin câteva rute pe care le implementezi tu. Exemplele sunt în Laravel, dar contractul e simplu HTTP + JSON: merge la fel în Symfony, Node, .NET sau altceva.

Cât durează: ruta de produse și /ping sunt lucru de o oră, dacă îți cunoști baza de date. Restul poate veni mai târziu.

Cum funcționează

Tu alegi o adresă de bază, de exemplu https://magazinul-tau.ro/api/asistent, și un token secret. Le lipești în dashboard, la pasul „Magazin" → „Magazin propriu". Asistentul întreabă întâi /ping, află ce ai implementat și îi oferă botului doar acele posibilități. Ce nu ai implementat nu există pentru el, deci nu are cum să promită clientului ceva ce magazinul nu face.

Se poate implementa în trepte:

Ce implementeziCe poate botul
/ping + /productscaută produse, spune preț și stoc, arată produsul ca un card cu poză și link
plus /orders/...răspunde la „unde e comanda mea", inclusiv AWB
plus /cancel, /addressanulează comenzi și schimbă adresa de livrare
plus POST /ordersplasează comenzi în numele clientului, cu link de plată sau ramburs
plus configured_itemscomandă și semne configurate în chat (text, font, mărime, culoare), cu poza simulării

Autentificare

Fiecare cerere vine cu antetul:

Authorization: Bearer <tokenul tău>

Verifică-l la fiecare rută și răspunde 401 dacă nu se potrivește. Folosește un token lung și aleatoriu (Str::random(48)), ține-l în .env și servește totul peste HTTPS.

Tokenul îl poate genera oricare dintre voi: tu, cu Str::random(48), sau comerciantul, din dashboard („Generează un token”, apoi „Copiază”). Important e să fie același în .env-ul magazinului și în dashboard, și să circule pe un canal privat, nu pe email în clar.

// routes/api.php
Route::prefix('asistent')->middleware('asistent.token')->group(function () {
    Route::get('/ping', [AsistentController::class, 'ping']);
    Route::get('/products', [AsistentController::class, 'products']);
    Route::get('/products/{id}', [AsistentController::class, 'product']);
    Route::get('/orders', [AsistentController::class, 'ordersByEmail']);
    Route::get('/orders/{number}', [AsistentController::class, 'order']);
    Route::post('/orders/{id}/notes', [AsistentController::class, 'addNote']);
    Route::post('/orders/{id}/cancel', [AsistentController::class, 'cancel']);
    Route::post('/orders/{id}/address', [AsistentController::class, 'address']);
    Route::post('/orders', [AsistentController::class, 'create']);
});
// app/Http/Middleware/AsistentToken.php
public function handle(Request $request, Closure $next)
{
    $expected = config('services.asistent.token');
    $given = $request->bearerToken();
    // hash_equals: comparatia obisnuita scurge lungimea tokenului prin timp.
    if (! $expected || ! $given || ! hash_equals($expected, $given)) {
        return response()->json(['message' => 'Unauthorized'], 401);
    }
    return $next($request);
}

Limba clientului

Când clientul scrie din widgetul de pe site, fiecare cerere vine și cu ?locale=<cod>: limba paginii de pe care scrie, luată din <html lang> și redusă la cod (it-IT → it). Pe un site cu mai multe limbi sau domenii, folosește-o ca să întorci numele și descrierile traduse, url/buy_link pe domeniul potrivit și textele pentru client (status_label, detail) în limba lui.

  • Lipsește (client pe WhatsApp sau Messenger, pagină fără lang) → limba implicită a magazinului.
  • O limbă pe care n-o ai (pl, de exemplu) → tot limba implicită, nu o eroare: altfel clientul acela n-ar mai găsi niciun produs.

Asistentul răspunde în limba în care scrie clientul când la limbă e aleasă „Detectează automat limba clientului” în dashboard; locale face ca și produsele să vină în aceeași limbă.

Rutele

GET /ping — obligatorie

Spune că ești viu și ce ai implementat.

{
  "ok": true,
  "shop": "Magazinul Meu",
  "supports": { "orders": true, "cancel": false, "address": false, "create_orders": false, "configured_items": false }
}

Ce lipsește din supports se consideră nu. Pornește cu toate pe false în afară de ce ai scris deja.

public function ping()
{
    return response()->json([
        'ok' => true,
        'shop' => config('app.name'),
        'supports' => ['orders' => true, 'cancel' => false, 'address' => false, 'create_orders' => false, 'configured_items' => false],
    ]);
}

GET /products?q=<text>&limit=5 — obligatorie

Căutare de produse. Întoarce cel mult limit rezultate.

{
  "products": [
    {
      "id": "P-1",
      "name": "Ghirlandă Luminoasă 10m",
      "price": "197",
      "currency": "RON",
      "in_stock": true,
      "stock_quantity": 12,
      "url": "https://magazinul-tau.ro/ghirlanda-10m",
      "image": "https://magazinul-tau.ro/img/ghirlanda-10m.jpg",
      "description": "Text scurt; HTML-ul e curățat de noi.",
      "buy_link": "https://magazinul-tau.ro/cos?add=P-1",
      "variants": [
        { "id": "P-1-M", "label": "Marime: M", "price": "197", "in_stock": true, "stock_quantity": 4,
          "buy_link": "https://magazinul-tau.ro/cos?add=P-1-M" }
      ]
    }
  ]
}

Obligatorii: id, name, price, url. Restul sunt opționale. Produse făcute la comandă: dacă nu ții stoc (le faci după comandă), trimite "in_stock": true fără stock_quantity și adaugă "made_to_order": true, plus, opțional, "lead_time": "7–10 zile lucrătoare". Asistentul spune atunci „se face la comandă, în 7–10 zile lucrătoare”, nu „în stoc”. image este poza principală (URL absolut): în chat, asistentul arată produsul ca un card cu poză, nume, preț și buton, nu doar ca un link — fără image, cardul apare fără poză. Dă o miniatură de 300–400 px, nu originalul de câțiva MB: cardul o afișează la 72 px, adesea pe un telefon pe date mobile. buy_link lipsă înseamnă că botul trimite clientul pe pagina produsului — perfect acceptabil. variants doar dacă produsul chiar are mărimi sau culori; atunci botul întreabă clientul ce variantă vrea înainte să dea linkul. Fiecare variantă poate avea propriul buy_link (coșul cu exact acea mărime); fără el, botul dă pagina produsului și îi spune clientului să aleagă varianta acolo — niciodată buy_link-ul părintelui, care ar pune în coș altceva. Dacă lipsește și url, produsul rămâne util (preț, stoc, comandă din chat), dar botul spune sincer că nu are link de dat; nu inventează unul.

Caută fără diacritice. Clienții scriu „ghirlanda", nu „ghirlandă".

public function products(Request $request)
{
    $q = trim((string) $request->query('q', ''));
    $limit = min((int) $request->query('limit', 5), 20);

    $products = Product::query()
        ->where('is_active', true)
        ->when($q !== '', function ($query) use ($q) {
            // unaccent/LIKE pe o coloana normalizata: clientii scriu fara diacritice
            $query->whereRaw('unaccent(lower(name)) LIKE unaccent(lower(?))', ["%{$q}%"]);
        })
        ->limit($limit)
        ->get();

    return response()->json([
        'products' => $products->map(fn ($p) => [
            'id' => (string) $p->id,
            'name' => $p->name,
            'price' => (string) $p->price,
            'currency' => 'RON',
            'in_stock' => $p->stock > 0,
            'stock_quantity' => $p->stock,
            'url' => route('produs', $p->slug),
            'image' => $p->image_url,
            'description' => $p->short_description,
        ]),
    ]);
}

GET /products/{id} — obligatorie

Un singur produs, în aceeași formă: { "product": { ... } }. Dacă nu există, răspunde 404.

GET /orders/{number}?email=<email> — pentru comenzi

Regula de aur: dacă parametrul email este prezent și nu se potrivește cu emailul comenzii, răspunde 404, nu comanda. Așa un client nu poate afla comanda altuia ghicind numere. Noi verificăm încă o dată la primire, dar prima apărare e la tine.

{
  "order": {
    "id": "77",
    "number": "1001",
    "email": "ana@example.com",
    "status_label": "în pregătire",
    "created_at": "2026-09-01T10:00:00Z",
    "total": "394",
    "currency": "RON",
    "items": [{ "name": "Ghirlandă Luminoasă 10m", "quantity": 2 }],
    "cancellable": true,
    "address_editable": true,
    "tracking": { "number": "AWB999", "url": "https://...", "carrier": "Fan Courier" },
    "invoice": { "number": "FCT-2026-0042" }
  }
}

status_label e text pentru om, în română, cum îl vezi în adminul tău. cancellable și address_editable le decizi tu, din starea reală a comenzii — lipsa lor înseamnă „nu se poate". Regula pe care o folosim și la celelalte magazine: dacă există AWB, coletul a plecat, deci amândouă sunt false. invoice e opțional: doar numărul facturii, dacă ai emis-o. Când clientul cere factura, asistentul anunță echipa ta (notă pe comandă + email) ca s-o trimită pe emailul comenzii; factura nu ajunge niciodată în chat.

Fără parametrul email, ruta e folosită doar intern, ca să recitim starea unei comenzi pe care clientul deja și-a dovedit-o. Poți să o lași să întoarcă comanda; tokenul e granița de securitate.

public function order(Request $request, string $number)
{
    $order = Order::where('number', $number)->first();
    if (! $order) {
        return response()->json(['message' => 'not found'], 404);
    }

    $email = $request->query('email');
    // Prima aparare e aici: fara potrivire, comanda nu iese din magazin.
    if ($email !== null && ! hash_equals(mb_strtolower($order->email), mb_strtolower($email))) {
        return response()->json(['message' => 'not found'], 404);
    }

    $shipped = $order->awb !== null;

    return response()->json(['order' => [
        'id' => (string) $order->id,
        'number' => $order->number,
        'email' => $order->email,
        'status_label' => $order->status_label,
        'created_at' => $order->created_at->toIso8601String(),
        'total' => (string) $order->total,
        'currency' => 'RON',
        'items' => $order->items->map(fn ($i) => ['name' => $i->name, 'quantity' => $i->quantity]),
        // Un colet cu AWB a plecat: nu se mai anuleaza si nu i se mai schimba adresa.
        'cancellable' => ! $shipped && in_array($order->status, ['nou', 'in_pregatire']),
        'address_editable' => ! $shipped && in_array($order->status, ['nou', 'in_pregatire']),
        'tracking' => $shipped ? ['number' => $order->awb, 'carrier' => $order->curier] : null,
    ]]);
}

GET /orders?email=<email>&limit=5 — pentru comenzi

Ultimele comenzi ale unui client, ca listă: { "orders": [ ... ] }, aceeași formă. Folosită la „unde e comanda mea?" spus fără număr.

POST /orders/{id}/notes — pentru comenzi

Corp: { "note": "text" }. Adaugă o notă internă pe comandă (așa ajung cererile de retur și de factură la echipa ta). Răspunde 200. Nu trebuie să trimiți tu vreun email: la fiecare cerere, noi anunțăm pe email membrii echipei din contul OmniChat al magazinului (proprietar, administratori, agenți).

POST /orders/{id}/cancel — opțional

Corp: { "reason": "motivul clientului" }.

{ "ok": true, "detail": "Comanda 1001 a fost anulată." }

Sau, dacă refuzi: { "ok": false, "detail": "de ce nu se poate" } — textul ajunge la client, așa că scrie-l pe înțelesul lui. Banii nu se returnează automat; asta rămâne decizia ta.

POST /orders/{id}/address — opțional

Corp: { "address1", "city", "county", "postcode", "country", "phone" }. Aceeași formă de răspuns ca la anulare. Actualizează doar câmpurile primite, fără să ștergi numele destinatarului.

POST /orders — opțional, pentru comenzi plasate din chat

Asistentul strânge produsele, adresa și telefonul, îi citește clientului comanda cu totalul și transportul, și o trimite aici doar după ce clientul a confirmat. Datele sunt deja verificate de noi (produse recitite din /products/{id}, variantă aleasă, cantitate 1–20, adresă completă, email și telefon valide); tu le validezi din nou, ca la orice API. Declară "create_orders": true în /ping; fără asta, comanda ajunge la echipa comerciantului ca cerere, iar un om o finalizează.

Corp:

{
  "items": [{ "product_id": "P-2", "variant_id": "P-2-M", "name": "Tricou Bumbac", "quantity": 1 }],
  "customer": { "name": "Ana Pop", "email": "ana@example.com", "phone": "0722000000" },
  "shipping_address": { "address1": "Str. Nouă 2, bl. A, ap. 3", "city": "Cluj-Napoca", "county": "Cluj", "postcode": "400001", "country": "RO", "phone": "0722000000" },
  "payment": "link",
  "shipping": { "label": "Transport", "amount": "19.90" },
  "currency": "RON",
  "customer_note": "sunați înainte de livrare",
  "note": "Comanda plasată prin asistentul AI, conversația c1."
}

payment este link (clientul plătește online, pe un link pe care îl dai tu) sau cod (ramburs, plata la curier). shipping este transportul stabilit de comerciant în dashboard, deja inclus în totalul citit clientului: pune-l pe comandă ca atare. variant_id este null la produsele fără variante.

Răspuns:

{
  "ok": true,
  "order": { "id": "90", "number": "1010", "total": "413.90", "currency": "RON" },
  "payment_url": "https://magazinul-tau.ro/plata/90?k=abc"
}

payment_url doar pentru payment: "link" — pagina ta de plată pentru acea comandă, cu tot ce trebuie ca să nu ceară clientului să se logheze. La cod, plasează comanda direct și lasă-l afară. Dacă refuzi (stoc epuizat între timp, adresă nelivrabilă), răspunde { "ok": false, "detail": "de ce" } — textul ajunge la client.

public function create(Request $request)
{
    $data = $request->validate([
        'items' => 'required|array|min:1|max:10',
        'items.*.product_id' => 'required_unless:items.*.type,configurat|string',
        'items.*.quote_id' => 'required_if:items.*.type,configurat|string',
        'items.*.quantity' => 'required|integer|min:1|max:20',
        'customer.email' => 'required|email',
        'customer.phone' => 'required|string',
        'shipping_address.address1' => 'required|string',
        'shipping_address.city' => 'required|string',
        'shipping_address.postcode' => 'required|string',
        'payment' => 'required|in:link,cod',
    ]);
    // Creezi comanda exact ca din checkout-ul site-ului: aceleași verificări de
    // stoc, aceleași stări, aceleași emailuri de confirmare către client.
    $order = Order::createFromAssistant($data);
    return response()->json([
        'ok' => true,
        'order' => ['id' => (string) $order->id, 'number' => $order->number, 'total' => (string) $order->total, 'currency' => 'RON'],
        'payment_url' => $data['payment'] === 'link' ? route('plata', $order) : null,
    ]);
}

Produse configurate în chat (semne) — opțional

Dacă folosiți și configuratorul de semne (SignStudio), asistentul îi arată clientului semnul simulat (poza, dimensiunile, prețul) și îl poate pune pe comandă, nu doar produse din catalog. Declară "configured_items": true în /ping, împreună cu "create_orders": true; fără el, o comandă cu un semn ajunge la echipă ca cerere.

O astfel de linie din items arată așa:

{
  "type": "configurat",
  "quote_id": "7fc3hGm8B70E",
  "image_url": "https://app.omnichat.ro/api/semne/3f9c0a…e1.jpg",
  "name": "Semn LED „Salut”",
  "details": "text „Salut”, font Amsterdam, litere 20 cm, culoare Cyan, placa Plexiglas 8mm Transparent, prindere Kit 4 distantiere, interior, placa 50,2 x 25,2 cm, 16 W",
  "quantity": 1
}

Ce faci cu ea:

  1. Prețul și detaliile le citești din ofertă, cu GET /quote/{quote_id} la configurator, exact ca în coșul site-ului. Cererea noastră nu are preț pe linie, iar details e doar pentru ochii omului. Dacă oferta a expirat sau e nefabricabil, refuzi comanda cu { "ok": false, "detail": "de ce" }. Prețul ofertei e pentru toată cantitatea: oferta e creată cu cantitate egală cu quantity de pe linie. Nu înmulți prețul ofertei cu quantity, iar la Calculator trimite doar quote_id — el citește cantitatea din ofertă.
  2. Copiezi poza de la image_url și o păstrezi cu comanda: o arăți în admin și în emailul de confirmare. Linkul nostru nu e veșnic — pozele mai vechi de 90 de zile se șterg.
  3. Un quote_id, o singură comandă. Dacă primești același quote_id a doua oară (s-a rupt conexiunea după ce ai creat comanda, iar asistentul reîncearcă), întoarce comanda deja creată, cu ok: true — nu una nouă. Altfel atelierul taie două semne. Un index unic pe quote_id rezolvă asta.
  4. Spre atelier, pe drumul obișnuit: trimiți quote_id la Calculator ca la orice comandă din configurator, în momentul în care o trimiți și pe cea din coș: de exemplu, la ramburs când echipa preia comanda (ramburs: true), la plata online după ce s-a plătit.
foreach ($data['items'] as $item) {
    if (($item['type'] ?? null) !== 'configurat') continue;
    // Pretul si detaliile vin din oferta, niciodata din cererea asistentului.
    $oferta = Http::timeout(10)->get(config('services.signstudio.url').'/quote/'.$item['quote_id'])->json();
    if (! $oferta || ! empty($oferta['expirat']) || ! empty($oferta['nefabricabil'])) {
        return response()->json(['ok' => false, 'detail' => 'Oferta semnului nu mai e valabilă; cereți o simulare nouă.']);
    }
    $existenta = Order::where('quote_id', $item['quote_id'])->first();
    if ($existenta) {
        return response()->json(['ok' => true, 'order' => $existenta->toContract()]);   // reincercare, nu comanda noua
    }
    // ... linia de comanda: $oferta['config'] (text, font, inaltime), $oferta['pret']['total'] (cu TVA)
    // ... poza: Storage::put("semne/{$item['quote_id']}.jpg", Http::get($item['image_url'])->body());
}

Ce faci tu, ce facem noi. Noi: confirmarea explicită a clientului, plafoane (două comenzi pe conversație, trei pe zi pe același email), emailul confirmat prin cod la ramburs, jurnal de audit. Tu: stocul real la momentul creării, emailul de confirmare către client, tot ce ține de plată.

Ce nu ai implementat

Răspunde 501 Not Implemented (sau lasă ruta să dea 404) și declară false în /ping. Botul nu va primi acele unelte deloc.

Verificare

În dashboard, „Testează conexiunea" apelează /ping și /products și îți spune exact ce a găsit. Din terminal:

curl -H "Authorization: Bearer TOKENUL" https://magazinul-tau.ro/api/asistent/ping
curl -H "Authorization: Bearer TOKENUL" "https://magazinul-tau.ro/api/asistent/products?q=ghirlanda&limit=2"

Un magazin fals care implementează contractul întocmai, folosit de testele noastre, este în tests/custom-store.mts — util ca referință când nu ești sigur de o formă.

Reguli de bună purtare

  • HTTPS obligatoriu. Adresele http:// sunt refuzate la salvare.
  • Răspunde repede. Peste 15 secunde considerăm cererea căzută.
  • Nu întoarce date personale în plus. Doar ce e în forme de mai sus; fără CNP, fără date de card, fără istoricul complet al clientului.
  • Textele sunt pentru clientul final. status_label și detail ajung, ca atare, în chat.

Ai întrebări despre contract? Scrie-i persoanei care ți-a trimis linkul.