This commit is contained in:
2026-08-17 21:20:45 +02:00
parent 321f11e817
commit 6fcdc74843
157 changed files with 2895 additions and 7544 deletions
+6
View File
@@ -58,3 +58,9 @@ scripts/_layout_snip.js
scripts/write_001.py
apps/web/scripts/_*
apps/web/scripts/tr-*
# Never ship local browser-audit dumps as public static
apps/web/static/.audit/
# codehelper (generated local — do not commit)
.zed/
+2 -2
View File
@@ -4,13 +4,13 @@ import (
"context"
"fmt"
"log"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/mail"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -37,7 +37,7 @@ func webOrigin() string {
}
func setPasswordInviteURL(token string) string {
return webOrigin() + "/accept-invite?token=" + url.QueryEscape(token)
return mail.AcceptInviteURL(webOrigin(), token)
}
// prepareSetPasswordHooks creates invites for active users with must_set_password=true.
+1 -1
View File
@@ -10,7 +10,7 @@ import (
func TestSetPasswordInviteURL(t *testing.T) {
t.Setenv("WEB_ORIGIN", "https://app.example.com/")
got := setPasswordInviteURL("tok+1")
wantPrefix := "https://app.example.com/accept-invite?token="
wantPrefix := "https://app.example.com/accept-invite#token="
if !strings.HasPrefix(got, wantPrefix) {
t.Fatalf("got %q", got)
}
+2 -1
View File
@@ -20,6 +20,8 @@ func ClientError(err error) (msg string, ok bool) {
switch {
case err == nil:
return "", false
case errors.Is(err, ErrSyntheticEmail):
return "this email cannot receive invites", true
case errors.Is(err, ErrRegisterFieldsRequired),
errors.Is(err, ErrPasswordTooShort),
errors.Is(err, ErrPasswordAlreadySet),
@@ -32,7 +34,6 @@ func ClientError(err error) (msg string, ok bool) {
errors.Is(err, ErrInviteNotFound),
errors.Is(err, ErrTokenInvalid),
errors.Is(err, ErrEmailRequired),
errors.Is(err, ErrSyntheticEmail),
errors.Is(err, ErrNotEligibleSetPassword),
errors.Is(err, ErrEmailMismatch),
errors.Is(err, ErrNotCompanyOwner),
+15
View File
@@ -1,6 +1,7 @@
package auth
import (
"strings"
"testing"
)
@@ -18,3 +19,17 @@ func TestOwnershipErrorClientFacing(t *testing.T) {
}
}
}
func TestClientError_syntheticEmailPublicString(t *testing.T) {
t.Parallel()
msg, ok := ClientError(ErrSyntheticEmail)
if !ok {
t.Fatal("ErrSyntheticEmail should be a client error")
}
if msg != "this email cannot receive invites" {
t.Fatalf("public msg=%q", msg)
}
if strings.Contains(msg, "synthetic") || strings.Contains(msg, "migration") {
t.Fatalf("internal wording leaked: %q", msg)
}
}
+2 -2
View File
@@ -1265,7 +1265,7 @@ func (s *Service) GetProcessedProduct(ctx context.Context, companyID, id uuid.UU
item["content_language"] = primary
item["content_languages"] = company.LoadContentLanguages(ctx, s.Pool, companyID)
if loc, err := company.DecodeLocalizedContent(item["localized_content"]); err == nil {
item["localized_content"] = loc
item["localized_content"] = loc.Public()
}
return item, nil
}
@@ -1328,7 +1328,7 @@ func (s *Service) GetRawProduct(ctx context.Context, companyID, id uuid.UUID) (m
item["content_language"] = primary
item["content_languages"] = company.LoadContentLanguages(ctx, s.Pool, companyID)
if loc, err := company.DecodeLocalizedContent(item["localized_content"]); err == nil {
item["localized_content"] = loc
item["localized_content"] = loc.Public()
}
return item, nil
}
+15
View File
@@ -31,6 +31,21 @@ type LocalizedFields struct {
// LocalizedContent is language-code → per-language product output fields.
type LocalizedContent map[string]LocalizedFields
// Public returns a copy safe for API/UI clients. Drops enhance_input_hash
// (pipeline cache key) so GET product payloads cannot leak job internals.
// UpdateProcessedProduct merges from DB, so omitting the hash on GET is safe.
func (c LocalizedContent) Public() LocalizedContent {
if len(c) == 0 {
return c
}
out := make(LocalizedContent, len(c))
for lang, fields := range c {
fields.EnhanceInputHash = ""
out[lang] = fields
}
return out
}
// SanitizeLangPromptMap validates language codes, sanitizes prompts, and drops empties.
// Accepts LangPromptAny ("*") as a shared any-language prompt key.
func SanitizeLangPromptMap(in map[string]string, maxRunes int) (LangPromptMap, error) {
@@ -2,6 +2,7 @@ package company
import (
"encoding/json"
"strings"
"testing"
)
@@ -101,3 +102,32 @@ func TestLocalizedContentRoundTrip(t *testing.T) {
t.Fatalf("got %#v", f)
}
}
func TestLocalizedContentPublicOmitsEnhanceHash(t *testing.T) {
t.Parallel()
c := LocalizedContent{
"sl": {
ProcessedName: "Naslov",
ProcessedDescription: "Opis",
MetaTitle: "Meta",
EnhanceInputHash: "deadbeef",
},
}
pub := c.Public()
if pub["sl"].EnhanceInputHash != "" {
t.Fatalf("public hash leaked: %#v", pub["sl"])
}
if pub["sl"].ProcessedName != "Naslov" || pub["sl"].MetaTitle != "Meta" {
t.Fatalf("public stripped too much: %#v", pub["sl"])
}
if c["sl"].EnhanceInputHash != "deadbeef" {
t.Fatalf("Public must not mutate storage copy: %#v", c["sl"])
}
b, err := json.Marshal(pub)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), "enhance_input_hash") || strings.Contains(string(b), "deadbeef") {
t.Fatalf("hash in JSON: %s", b)
}
}
@@ -9,7 +9,7 @@ import (
func TestAcceptInviteURL(t *testing.T) {
t.Parallel()
got := mail.AcceptInviteURL("http://localhost:28472/", "abc123")
want := "http://localhost:28472/accept-invite?token=abc123"
want := "http://localhost:28472/accept-invite#token=abc123"
if got != want {
t.Fatalf("AcceptInviteURL = %q want %q", got, want)
}
@@ -7,6 +7,7 @@ import (
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
@@ -403,6 +404,7 @@ func (s *Server) handleListProducts(w http.ResponseWriter, r *http.Request) {
if detailed {
attachProductQuality(items)
}
stripCatalogSEOMetaIfOmitted(processing.CompanyOmitsSEOMeta(r.Context(), s.Pool, cid), items...)
resp := map[string]any{"products": items, "total": total, "kind": "processed", "limit": limit, "offset": offset, "detailed": detailed}
if nextCursor, nextAfter := catalog.NextProductCursor(f, items, limit); nextCursor != "" || nextAfter != "" {
if nextCursor != "" {
@@ -438,6 +440,7 @@ func (s *Server) handleGetProduct(w http.ResponseWriter, r *http.Request) {
return
}
}
stripCatalogSEOMetaIfOmitted(processing.CompanyOmitsSEOMeta(r.Context(), s.Pool, cid), item)
JSON(w, http.StatusOK, item)
}
@@ -458,5 +461,28 @@ func (s *Server) handleUpdateProduct(w http.ResponseWriter, r *http.Request) {
ClientOrLog(w, http.StatusBadRequest, "could not update product", err, catalog.ClientError)
return
}
stripCatalogSEOMetaIfOmitted(processing.CompanyOmitsSEOMeta(r.Context(), s.Pool, cid), item)
JSON(w, http.StatusOK, item)
}
// stripCatalogSEOMetaIfOmitted drops meta_title / meta_description from dashboard
// product payloads when omit is true (same detection as V1 process / CompanyOmitsSEOMeta).
func stripCatalogSEOMetaIfOmitted(omit bool, items ...map[string]any) {
if !omit {
return
}
for _, item := range items {
if item == nil {
continue
}
delete(item, "meta_title")
delete(item, "meta_description")
if loc, ok := item["localized_content"].(company.LocalizedContent); ok {
for lang, fields := range loc {
fields.MetaTitle = ""
fields.MetaDescription = ""
loc[lang] = fields
}
}
}
}
@@ -0,0 +1,76 @@
package httpapi
import (
"testing"
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/google/uuid"
)
func catalogSEOItem() map[string]any {
return map[string]any{
"name": "Widget",
"meta_title": "T",
"meta_description": "D",
"localized_content": company.LocalizedContent{
"sl": {ProcessedName: "N", MetaTitle: "LT", MetaDescription: "LD", EnhanceInputHash: "abc"},
},
}
}
func assertCatalogSEOOmitted(t *testing.T, item map[string]any) {
t.Helper()
if _, ok := item["meta_title"]; ok {
t.Fatalf("must omit meta_title: %v", item)
}
if _, ok := item["meta_description"]; ok {
t.Fatalf("must omit meta_description: %v", item)
}
loc := item["localized_content"].(company.LocalizedContent)
if loc["sl"].MetaTitle != "" || loc["sl"].MetaDescription != "" {
t.Fatalf("localized meta leaked: %#v", loc["sl"])
}
if loc["sl"].ProcessedName != "N" {
t.Fatalf("stripped too much: %#v", loc["sl"])
}
}
func TestStripCatalogSEOMetaIfOmitted(t *testing.T) {
t.Parallel()
a1 := uuid.MustParse("604f23a8-b66e-4b21-8b45-0d72b68f4790")
other := uuid.MustParse("11111111-1111-1111-1111-111111111111")
item := catalogSEOItem()
stripCatalogSEOMetaIfOmitted(processing.CompanyOmitsSEOMetaLookup(other, "", "Acme", false), item)
if item["meta_title"] != "T" {
t.Fatalf("ordinary company must keep meta: %v", item["meta_title"])
}
stripCatalogSEOMetaIfOmitted(processing.CompanyOmitsSEOMetaLookup(a1, "", "", false), item)
assertCatalogSEOOmitted(t, item)
}
func TestStripCatalogSEOMetaIfOmitted_demoByName(t *testing.T) {
t.Parallel()
other := uuid.MustParse("11111111-1111-1111-1111-111111111111")
omit := processing.CompanyOmitsSEOMetaLookup(other, "", " platform demo ", false)
if !omit {
t.Fatal("Platform Demo by name must omit SEO meta")
}
item := catalogSEOItem()
stripCatalogSEOMetaIfOmitted(omit, item)
assertCatalogSEOOmitted(t, item)
}
func TestStripCatalogSEOMetaIfOmitted_a1PromptMarkers(t *testing.T) {
t.Parallel()
other := uuid.MustParse("11111111-1111-1111-1111-111111111111")
omit := processing.CompanyOmitsSEOMetaLookup(other, "", "Acme", true)
if !omit {
t.Fatal("A1-style prompt markers must omit SEO meta")
}
item := catalogSEOItem()
stripCatalogSEOMetaIfOmitted(omit, item)
assertCatalogSEOOmitted(t, item)
}
@@ -24,6 +24,23 @@ func presentV1Category(item map[string]any) map[string]any {
}
}
// handleV1GetCategory serves GET /api/v1/categories/{id} as the public list DTO
// (no prompts, formulas, or has_prompt). Dashboard GET stays on handleGetCategory.
func (s *Server) handleV1GetCategory(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
item, err := s.Catalog.GetCategory(r.Context(), cid, id)
if err != nil {
Error(w, http.StatusNotFound, "not found")
return
}
v1OK(w, http.StatusOK, presentV1Category(item), nil)
}
func presentV1Attribute(item map[string]any) map[string]any {
out := map[string]any{
"id": item["id"],
@@ -17,6 +17,9 @@ func TestPresentV1CategoryAndAttribute(t *testing.T) {
cat := presentV1Category(map[string]any{
"id": id, "unique_id": "electronics", "name": "Electronics",
"created_at": ts, "updated_at": ts,
"prompt": "--- Title --- secret", "prompts": map[string]string{"en": "x"},
"has_prompt": true, "title_template": []any{"--- Title ---"},
"description_template": []any{"x"},
})
if cat["unique_id"] != "electronics" || cat["name"] != "Electronics" {
t.Fatalf("category=%v", cat)
@@ -24,6 +27,11 @@ func TestPresentV1CategoryAndAttribute(t *testing.T) {
if cat["created_at"] != "2026-07-01T08:00:00Z" {
t.Fatalf("created_at=%v", cat["created_at"])
}
for _, leak := range []string{"prompt", "prompts", "has_prompt", "title_template", "description_template"} {
if _, ok := cat[leak]; ok {
t.Fatalf("public category leaked %q: %v", leak, cat)
}
}
attr := presentV1Attribute(map[string]any{
"id": id, "attribute_key": "color", "name": "Color", "value_type": "string",
@@ -41,11 +49,11 @@ func TestPresentV1CategoryAndAttribute(t *testing.T) {
func TestV1OpenAPICategoriesAttributesLegacyContract(t *testing.T) {
body := string(v1OpenAPIYAML)
needles := []string{
"LegacyCategoriesResponse",
"LegacyAttributesResponse",
"LegacyCategoryCreateResponse",
"LegacyAttributeCreateResponse",
"LegacySuccessMessage",
"CategoriesEnvelope",
"AttributesEnvelope",
"CategoryCreateEnvelope",
"AttributeCreateEnvelope",
"SuccessMessage",
"category_unique_id",
"attribute_key",
"parent_id",
@@ -10,7 +10,7 @@ import (
func TestInviteAcceptURLAlwaysShareable(t *testing.T) {
t.Parallel()
url := mail.AcceptInviteURL("https://app.example.com/", "tok_abc")
want := "https://app.example.com/accept-invite?token=tok_abc"
want := "https://app.example.com/accept-invite#token=tok_abc"
if url != want {
t.Fatalf("AcceptInviteURL = %q want %q", url, want)
}
+29
View File
@@ -413,6 +413,35 @@ func TestRouterMetricsHiddenInProductionForRemote(t *testing.T) {
}
}
func TestRouterMetricsHiddenInProductionForSpoofedLoopbackXFF(t *testing.T) {
t.Parallel()
s := testAPIServer()
s.Config.AppEnv = "production"
s.Config.MetricsPublic = false
s.Config.TrustedProxies = []string{"10.0.0.0/8"}
h := s.Router()
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
req.RemoteAddr = "10.0.0.5:443"
req.Header.Set("X-Forwarded-For", "127.0.0.1")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("trusted proxy spoofed loopback XFF status=%d want 404", rec.Code)
}
s.Config.MetricsPublic = true
hPub := s.Router()
reqPub := httptest.NewRequest(http.MethodGet, "/metrics", nil)
reqPub.RemoteAddr = "10.0.0.5:443"
reqPub.Header.Set("X-Forwarded-For", "127.0.0.1")
recPub := httptest.NewRecorder()
hPub.ServeHTTP(recPub, reqPub)
if recPub.Code != http.StatusOK {
t.Fatalf("METRICS_PUBLIC trusted proxy XFF status=%d", recPub.Code)
}
}
func assertGateBody(t *testing.T, rec *httptest.ResponseRecorder, errorCode string, maintenance, readOnly bool) {
t.Helper()
var body map[string]any
@@ -119,6 +119,7 @@ func (s *Server) handleGetProcessingJob(w http.ResponseWriter, r *http.Request)
Error(w, http.StatusInternalServerError, "load failed")
return
}
items = processing.ApplyV1SEOMetaPolicy(cid, items)
JSON(w, http.StatusOK, processing.FormatJobStatusResponse(job, items, true))
return
}
@@ -1,6 +1,7 @@
package httpapi
import (
"errors"
"net/http"
"strconv"
"strings"
@@ -8,6 +9,10 @@ import (
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
"github.com/descrybe/descrybe-v2/apps/api/internal/marketing"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
const (
@@ -88,6 +93,49 @@ func presentV1Product(item map[string]any) map[string]any {
}
}
func presentV1ProductDetail(item map[string]any) map[string]any {
out := presentV1Product(item)
desc := firstNonEmpty(asMapString(item["processed_description"]), asMapString(item["description"]))
if desc == "" {
out["description"] = nil
} else {
out["description"] = desc
}
out["attributes"] = firstPublicAttributes(item)
mapped, _ := item["mapped_data"].(map[string]any)
main, more := catalog.ExtractProductImages(mapped, nil)
if main == "" {
out["main_image"] = nil
} else {
out["main_image"] = main
}
if len(more) > 0 {
out["more_images"] = more
} else {
out["more_images"] = nil
}
if eprel, ok := item["eprel"]; ok {
out["eprel"] = eprel
} else {
out["eprel"] = nil
}
return out
}
func firstPublicAttributes(item map[string]any) any {
for _, key := range []string{"processed_attributes", "attributes"} {
v := item[key]
if v == nil {
continue
}
if m, ok := v.(map[string]any); ok && len(m) == 0 {
continue
}
return v
}
return map[string]any{}
}
func nullIfEmptyAny(v any) any {
if v == nil {
return nil
@@ -150,6 +198,36 @@ func (s *Server) handleV1ListProducts(w http.ResponseWriter, r *http.Request) {
v1OK(w, http.StatusOK, data, v1ProductListMeta(page, limit, total))
}
// handleV1GetProduct serves GET /api/v1/products/{id} as a public DTO aligned with
// process items (name, description, attributes, images, eprel). Dashboard GET stays
// on handleGetProduct.
func (s *Server) handleV1GetProduct(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
item, err := s.Catalog.GetProcessedProduct(r.Context(), cid, id)
if err != nil {
if !errors.Is(err, pgx.ErrNoRows) {
Error(w, http.StatusInternalServerError, "lookup failed")
return
}
item, err = s.Catalog.GetRawProduct(r.Context(), cid, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
Error(w, http.StatusNotFound, "not found")
return
}
Error(w, http.StatusInternalServerError, "lookup failed")
return
}
}
stripCatalogSEOMetaIfOmitted(processing.CompanyOmitsSEOMeta(r.Context(), s.Pool, cid), item)
v1OK(w, http.StatusOK, presentV1ProductDetail(item), nil)
}
// handleV1ListProductQuality serves GET /api/v1/products/quality with the legacy
// { data, meta } envelope (quality rows + page/limit/total).
func (s *Server) handleV1ListProductQuality(w http.ResponseWriter, r *http.Request) {
@@ -56,6 +56,51 @@ func TestPresentV1ProductFields(t *testing.T) {
}
}
func TestPresentV1ProductDetailOmitsMappedData(t *testing.T) {
ts := time.Date(2026, 8, 1, 10, 15, 0, 0, time.UTC)
row := map[string]any{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"product_id": "SKU-1001",
"name": "Wireless earbuds",
"processed_name": "Acme Wireless Earbuds",
"category": "electronics/audio",
"status": "completed",
"feed_id": "22222222-2222-2222-2222-222222222222",
"description": "raw desc",
"processed_description": "A detailed product description that is long enough for scoring.",
"attributes": map[string]any{"color": "Black"},
"processed_attributes": map[string]any{"color": "Midnight"},
"mapped_data": map[string]any{"image": "https://example.com/earbuds.jpg", "main_image": "https://example.com/main.jpg"},
"has_processed_name": true,
"has_eprel": false,
"eprel": nil,
"created_at": ts,
"updated_at": ts,
}
out := presentV1ProductDetail(row)
for _, key := range []string{"name", "description", "attributes", "main_image", "more_images", "eprel"} {
if _, ok := out[key]; !ok {
t.Fatalf("missing field %q", key)
}
}
for _, leak := range []string{
"mapped_data", "has_processed_name", "has_processed_description",
"has_attributes", "has_processed_attributes", "has_eprel",
"processed_name", "processed_description", "processed_attributes",
} {
if _, ok := out[leak]; ok {
t.Fatalf("unexpected internal field %q", leak)
}
}
if out["description"] != "raw desc" && out["description"] != "A detailed product description that is long enough for scoring." {
t.Fatalf("description=%v", out["description"])
}
attrs, _ := out["attributes"].(map[string]any)
if attrs["color"] != "Midnight" {
t.Fatalf("attributes=%v", out["attributes"])
}
}
func TestV1ProductStatusAll(t *testing.T) {
if got := v1ProductStatus("all"); got != "" {
t.Fatalf("all -> %q want empty", got)
@@ -99,7 +144,7 @@ func TestV1OpenAPIIncludesLegacyProductsEnvelope(t *testing.T) {
"name: sortBy",
"name: feedId",
"totalPages",
"LegacyLimit",
"PublicLimit",
"required: [data, meta]",
} {
if !strings.Contains(body, needle) {
+4 -5
View File
@@ -8,7 +8,6 @@ import (
"net/http"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
@@ -66,7 +65,7 @@ func (s *Server) mountV1(r chi.Router) {
// Not an alias of POST/GET /process (flat ProcessingJob).
r.Post("/products/process", s.handleV1StartProcess)
r.Get("/products/process/{id}", s.handleV1GetProcess)
r.Get("/products/{id}", s.handleGetProduct)
r.Get("/products/{id}", s.handleV1GetProduct)
r.Patch("/products/{id}", s.handleUpdateProduct)
// Content calendar — separate from email /api/campaigns (session UI).
@@ -79,7 +78,7 @@ func (s *Server) mountV1(r chi.Router) {
r.Get("/categories", s.handleV1ListCategories)
r.Post("/categories", s.handleV1CreateCategory)
r.Post("/categories/create", s.handleV1CreateCategory) // legacy alias
r.Get("/categories/{id}", s.handleGetCategory)
r.Get("/categories/{id}", s.handleV1GetCategory)
r.Patch("/categories/{id}", s.handleUpdateCategory)
r.Delete("/categories/{id}", s.handleV1DeleteCategory)
@@ -113,7 +112,7 @@ func (s *Server) mountV1(r chi.Router) {
// Dashboard-style jobs (flat JSON / 202). Prefer /products/process for legacy integrations.
r.Post("/process", s.handleStartProcessingJob)
r.Get("/process", s.handleV1ListProcessJobs)
r.Get("/process/{id}", s.handleGetProcessingJob)
r.Get("/process/{id}", s.handleV1GetProcessJob)
r.Post("/process/{id}/cancel", s.handleCancelProcessingJob)
r.Post("/process/{id}/terminate", s.handleCancelProcessingJob)
r.Post("/process/{id}/retry", s.handleRetryProcessingJob)
@@ -133,7 +132,7 @@ func (s *Server) handleV1ListProcessJobs(w http.ResponseWriter, r *http.Request)
Error(w, http.StatusInternalServerError, "list failed")
return
}
JSON(w, http.StatusOK, map[string]any{"jobs": processing.FormatListJobsResponse(items), "limit": limit})
JSON(w, http.StatusOK, map[string]any{"jobs": presentV1JobList(items), "limit": limit})
}
func (s *Server) handleV1OpenAPI(w http.ResponseWriter, r *http.Request) {
+1 -1
View File
@@ -118,7 +118,7 @@ func TestV1OpenAPIDocumentsPublicAPIAuth(t *testing.T) {
"Retry-After",
"rate limit exceeded",
"code: unauthorized",
"LegacyAPIError",
"CodedAPIError",
"security: []",
} {
if !strings.Contains(body, want) {
@@ -154,9 +154,9 @@ func TestV1OpenAPIIncludesProcessAndFeeds(t *testing.T) {
"raw_product_ids",
"items[].ean",
"process_id",
"LegacyStartProcessByEAN",
"StartProcessByEAN",
"StartProcessByRawIDs",
"LegacyProcessCompleted",
"ProcessCompletedExample",
"X-API-Key",
"https://descrybe.io/api/v1",
"BearerAuth",
@@ -185,7 +185,7 @@ func TestV1OpenAPIIncludesProcessAndFeeds(t *testing.T) {
"CSRFHeader",
"code: unauthorized",
"message: Unauthorized",
"legacy envelope",
"coded envelope",
"/process/{id}/retry:",
} {
if !strings.Contains(body, needle) {
@@ -123,12 +123,18 @@ func TestV1DomainResourceCRUD(t *testing.T) {
t.Fatalf("get category status=%d body=%s", rec.Code, rec.Body.String())
}
gotCat := decode(t, rec)
if _, hasData := gotCat["data"]; hasData {
t.Fatalf("GET /categories/{uuid} should be flat dashboard JSON, got envelope: %v", gotCat)
}
if gotCat["unique_id"] != catUnique {
gotCatData, _ := gotCat["data"].(map[string]any)
if gotCatData["unique_id"] != catUnique {
t.Fatalf("get category=%v", gotCat)
}
for _, leak := range []string{"prompt", "prompts", "has_prompt", "title_template", "description_template"} {
if _, ok := gotCatData[leak]; ok {
t.Fatalf("v1 GET category leaked %q: %v", leak, gotCatData)
}
if _, ok := gotCat[leak]; ok {
t.Fatalf("v1 GET category envelope leaked %q: %v", leak, gotCat)
}
}
rec = do(http.MethodPatch, "/api/v1/categories/"+catUUID.String(),
`{"name":"Electronics & Audio"}`)
@@ -323,12 +329,15 @@ func TestV1DomainResourceCRUD(t *testing.T) {
t.Fatalf("get product status=%d body=%s", rec.Code, rec.Body.String())
}
gotProd := decode(t, rec)
if _, hasData := gotProd["data"]; hasData {
t.Fatalf("GET /products/{id} should be flat JSON, got envelope: %v", gotProd)
}
if fmt.Sprint(gotProd["product_id"]) != "SKU-1001" {
gotProdData, _ := gotProd["data"].(map[string]any)
if fmt.Sprint(gotProdData["product_id"]) != "SKU-1001" {
t.Fatalf("get product=%v", gotProd)
}
for _, leak := range []string{"mapped_data", "has_processed_name", "has_eprel", "processed_name"} {
if _, ok := gotProdData[leak]; ok {
t.Fatalf("v1 GET product leaked %q: %v", leak, gotProdData)
}
}
rec = do(http.MethodPatch, "/api/v1/products/"+productID.String(),
`{"processed_name":"Acme Wireless Earbuds ANC Midnight","status":"completed"}`)
@@ -427,7 +436,7 @@ func mountV1DomainTestRouter(s *Server) http.Handler {
r.Route("/api/v1", func(r chi.Router) {
r.Get("/products", s.handleV1ListProducts)
r.Get("/products/quality", s.handleV1ListProductQuality)
r.Get("/products/{id}", s.handleGetProduct)
r.Get("/products/{id}", s.handleV1GetProduct)
r.Patch("/products/{id}", s.handleUpdateProduct)
r.Get("/marketing/calendar", s.handleGetMarketingCalendar)
@@ -438,7 +447,7 @@ func mountV1DomainTestRouter(s *Server) http.Handler {
r.Get("/categories", s.handleV1ListCategories)
r.Post("/categories", s.handleV1CreateCategory)
r.Post("/categories/create", s.handleV1CreateCategory)
r.Get("/categories/{id}", s.handleGetCategory)
r.Get("/categories/{id}", s.handleV1GetCategory)
r.Patch("/categories/{id}", s.handleUpdateCategory)
r.Delete("/categories/{id}", s.handleV1DeleteCategory)
@@ -480,9 +489,9 @@ func TestV1OpenAPIDocumentsDomainCRUDSurface(t *testing.T) {
"/marketing/calendar:",
"/products:",
"/feeds/{id}/sync-process-sample:",
"Flat category JSON",
"no prompts or formulas",
"flat PresentFeed",
"flat ProcessedProduct",
"without mapped_data",
"CategoryDetail",
"FeedDeleted",
"FeedMappings",
+1 -1
View File
@@ -9,7 +9,7 @@ func TestV1OpenAPIFeedsLegacyContract(t *testing.T) {
t.Parallel()
body := string(v1OpenAPIYAML)
for _, needle := range []string{
"Legacy public contract — { data: Feed[], meta: { page, limit, total } }",
"Paged list — { data: Feed[], meta: { page, limit, total } }",
"required: [name, item_path]",
"jobId:",
"FeedSyncResponse",
File diff suppressed because it is too large Load Diff
+160 -6
View File
@@ -415,25 +415,25 @@ func TestV1OpenAPIYAMLProcessDualIDsAndGatesDocumentsContracts(t *testing.T) {
}
}
// TestV1OpenAPIYAMLLegacyProcessItemEprelShape locks LegacyProcessItem.eprel
// TestV1OpenAPIYAMLProcessItemEprelShape locks ProcessItem.eprel
// to the live nested object from eprel.MergeInto / extractEPRELFromAttrs.
func TestV1OpenAPIYAMLLegacyProcessItemEprelShape(t *testing.T) {
func TestV1OpenAPIYAMLProcessItemEprelShape(t *testing.T) {
t.Parallel()
root := mustParseOpenAPIRoot(t, v1OpenAPIYAML)
comps, _ := root["components"].(map[string]any)
schemas, _ := comps["schemas"].(map[string]any)
item, _ := schemas["LegacyProcessItem"].(map[string]any)
item, _ := schemas["ProcessItem"].(map[string]any)
itemProps, _ := item["properties"].(map[string]any)
eprel, _ := itemProps["eprel"].(map[string]any)
eprelProps, _ := eprel["properties"].(map[string]any)
if eprelProps == nil {
t.Fatal("LegacyProcessItem.eprel.properties missing")
t.Fatal("ProcessItem.eprel.properties missing")
}
want := []string{"id", "label", "pdf", "energy_class", "energy_scale"}
for _, key := range want {
if _, ok := eprelProps[key]; !ok {
t.Fatalf("LegacyProcessItem.eprel missing property %q (live shape)", key)
t.Fatalf("ProcessItem.eprel missing property %q (live shape)", key)
}
}
if len(eprelProps) != len(want) {
@@ -441,7 +441,161 @@ func TestV1OpenAPIYAMLLegacyProcessItemEprelShape(t *testing.T) {
for k := range eprelProps {
keys = append(keys, k)
}
t.Fatalf("LegacyProcessItem.eprel properties = %v, want exactly %v", keys, want)
t.Fatalf("ProcessItem.eprel properties = %v, want exactly %v", keys, want)
}
}
func TestV1OpenAPIYAMLNoLegacyWording(t *testing.T) {
t.Parallel()
assertOpenAPIYAMLOmitsToken(t, "legacy")
}
func TestV1OpenAPIYAMLNoA1Wording(t *testing.T) {
t.Parallel()
// Case-sensitive: lowercase "a1" appears in UUIDs and must stay allowed.
assertOpenAPIYAMLOmitsToken(t, "A1")
}
func TestV1OpenAPIYAMLOmitsInternalPromptWording(t *testing.T) {
t.Parallel()
for _, token := range []string{
"role-section prompts",
"plain description, meta",
"after AI/manual edit",
} {
assertOpenAPIYAMLOmitsToken(t, token)
}
}
func TestV1OpenAPIYAMLProcessItemCompletedExamplesOmitLeakyShape(t *testing.T) {
t.Parallel()
root := mustParseOpenAPIRoot(t, v1OpenAPIYAML)
leaky := []string{"id", "title", "meta_title", "meta_description"}
assertProcessItems := func(where string, items []any) {
t.Helper()
if len(items) == 0 {
t.Fatalf("%s: expected completed ProcessItem example", where)
}
for i, raw := range items {
item, _ := raw.(map[string]any)
if item == nil {
t.Fatalf("%s[%d]: not an object", where, i)
}
if _, ok := item["ean"]; !ok {
t.Fatalf("%s[%d]: missing ean", where, i)
}
for _, k := range leaky {
if _, ok := item[k]; ok {
t.Fatalf("%s[%d] must omit %q (do not teach leaky ProcessItem shape)", where, i, k)
}
}
}
}
comps, _ := root["components"].(map[string]any)
examples, _ := comps["examples"].(map[string]any)
completed, _ := examples["ProcessCompletedExample"].(map[string]any)
val, _ := completed["value"].(map[string]any)
data, _ := val["data"].(map[string]any)
items, _ := data["items"].([]any)
assertProcessItems("ProcessCompletedExample", items)
paths, _ := root["paths"].(map[string]any)
productsProcess := openAPIJSONExamples(t, paths, "/products/process/{id}", "get", "200")
prodCompleted, _ := productsProcess["completed"].(map[string]any)
prodVal, _ := prodCompleted["value"].(map[string]any)
prodData, _ := prodVal["data"].(map[string]any)
prodItems, _ := prodData["items"].([]any)
assertProcessItems("GET /products/process/{id} completed", prodItems)
legacyProcess := openAPIJSONExamples(t, paths, "/process/{id}", "get", "200")
legacyCompleted, _ := legacyProcess["completed"].(map[string]any)
legacyVal, _ := legacyCompleted["value"].(map[string]any)
legacyItems, _ := legacyVal["items"].([]any)
assertProcessItems("GET /process/{id} completed", legacyItems)
}
func openAPIJSONExamples(t *testing.T, paths map[string]any, path, method, status string) map[string]any {
t.Helper()
p, _ := paths[path].(map[string]any)
op, _ := p[method].(map[string]any)
resps, _ := op["responses"].(map[string]any)
resp, _ := resps[status].(map[string]any)
content, _ := resp["content"].(map[string]any)
appJSON, _ := content["application/json"].(map[string]any)
examples, _ := appJSON["examples"].(map[string]any)
if examples == nil {
t.Fatalf("%s %s %s: missing application/json examples", method, path, status)
}
return examples
}
func assertOpenAPIYAMLOmitsToken(t *testing.T, token string) {
t.Helper()
doc := string(v1OpenAPIYAML)
if token == "legacy" {
doc = strings.ToLower(doc)
}
if i := strings.Index(doc, token); i >= 0 {
start := i - 40
if start < 0 {
start = 0
}
end := i + 40
if end > len(doc) {
end = len(doc)
}
t.Fatalf("public OpenAPI YAML must not mention %q (near %q)", token, doc[start:end])
}
}
func TestV1OpenAPIYAMLEmptySecurityOnlyOnPublicProbes(t *testing.T) {
t.Parallel()
root := mustParseOpenAPIRoot(t, v1OpenAPIYAML)
sec, _ := root["security"].([]any)
if len(sec) == 0 {
t.Fatal("document-level security must require API key schemes")
}
var hasBearer, hasAPIKey bool
for _, item := range sec {
m, _ := item.(map[string]any)
if _, ok := m["BearerAuth"]; ok {
hasBearer = true
}
if _, ok := m["ApiKeyAuth"]; ok {
hasAPIKey = true
}
}
if !hasBearer || !hasAPIKey {
t.Fatal("document-level security must include BearerAuth and ApiKeyAuth")
}
paths, _ := root["paths"].(map[string]any)
methods := []string{"get", "post", "put", "patch", "delete"}
var bad []string
for p, raw := range paths {
item, _ := raw.(map[string]any)
for _, m := range methods {
op, _ := item[m].(map[string]any)
if op == nil {
continue
}
rawSec, ok := op["security"]
if !ok {
continue
}
arr, _ := rawSec.([]any)
if len(arr) != 0 {
continue
}
if p != "/health" && p != "/openapi.yaml" {
bad = append(bad, m+" "+p)
}
}
}
if len(bad) > 0 {
t.Fatalf("empty security (no API key) on non-probe operations: %v", bad)
}
}
@@ -227,6 +227,7 @@ func (s *Server) handleV1GetProcess(w http.ResponseWriter, r *http.Request) {
v1Err(w, http.StatusInternalServerError, "internal_server_error", "Internal server error")
return
}
items = processing.ApplyV1SEOMetaPolicy(cid, items)
data := map[string]any{
"status": status,
"process_id": job.ID.String(),
@@ -245,8 +246,9 @@ func (s *Server) handleV1GetProcess(w http.ResponseWriter, r *http.Request) {
v1OK(w, http.StatusOK, data, nil)
case "FAILED":
errMsg := "Processing failed"
if job.Error != nil && *job.Error != "" {
errMsg = *job.Error
if job.Error != nil && strings.TrimSpace(*job.Error) != "" {
log.Printf("httpapi: v1 process job %s failed: %s", job.ID, redactForLog(*job.Error))
errMsg = publicV1JobError(*job.Error)
}
v1OK(w, http.StatusOK, map[string]any{
"status": status,
@@ -269,6 +271,99 @@ func (s *Server) handleV1GetProcess(w http.ResponseWriter, r *http.Request) {
}
}
func publicV1JobError(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return "Processing failed"
}
out := processing.PublicV1Error(raw)
if strings.TrimSpace(out) == "" {
return "Processing failed"
}
return out
}
func presentV1Job(job processing.Job) map[string]any {
out := map[string]any{
"id": job.ID,
"status": job.Status,
"total_products": job.TotalProducts,
"processed_products": job.ProcessedProducts,
"processing_type": processing.ProcessingTypeForAPIResponse(job.ProcessingType),
"created_at": formatV1Timestamp(job.CreatedAt),
}
if job.StartedAt != nil {
out["started_at"] = formatV1Timestamp(*job.StartedAt)
}
if job.CompletedAt != nil {
out["completed_at"] = formatV1Timestamp(*job.CompletedAt)
}
if job.Error != nil && strings.TrimSpace(*job.Error) != "" {
out["error"] = publicV1JobError(*job.Error)
}
return out
}
func presentV1JobList(jobs []processing.Job) []any {
if len(jobs) == 0 {
return []any{}
}
formatted := processing.FormatListJobsResponse(jobs)
out := make([]any, len(formatted))
for i, row := range formatted {
base := presentV1Job(jobs[i])
raw, err := json.Marshal(row)
if err != nil {
out[i] = base
continue
}
var extra map[string]any
if err := json.Unmarshal(raw, &extra); err != nil {
out[i] = base
continue
}
for _, k := range []string{"sibling_job_ids", "job_count", "total_products_queued"} {
if v, ok := extra[k]; ok {
base[k] = v
}
}
out[i] = base
}
return out
}
// handleV1GetProcessJob serves GET /api/v1/process/{id} with a public job DTO
// (no current_step, step notes, or company_id). Dashboard GET stays on handleGetProcessingJob.
func (s *Server) handleV1GetProcessJob(w http.ResponseWriter, r *http.Request) {
cid, ok := CompanyIDFromContext(r.Context())
if !ok || cid == uuid.Nil {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid id")
return
}
job, err := s.getV1ProcessJob(r.Context(), cid, id)
if err != nil {
Error(w, http.StatusNotFound, "not found")
return
}
out := presentV1Job(job)
if processing.JobStatusIncludesProducts(job.Status) {
items, loadErr := s.loadV1ProcessJobItems(r.Context(), cid, id, job.ProcessingType)
if loadErr != nil {
Error(w, http.StatusInternalServerError, "load failed")
return
}
items = processing.ApplyV1SEOMetaPolicy(cid, items)
out["items"] = items
out["total_items"] = len(items)
}
JSON(w, http.StatusOK, out)
}
func (s *Server) ensureRawV1Items(ctx context.Context, companyID uuid.UUID, items []catalog.V1ProcessItem) ([]uuid.UUID, []catalog.EnsureRawResult, []string, error) {
if s != nil && s.testEnsureRawV1Items != nil {
return s.testEnsureRawV1Items(ctx, companyID, items)
@@ -337,7 +337,6 @@ func TestHandleV1GetProcessCompletedIncludesProcessedItems(t *testing.T) {
t.Parallel()
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
jobID := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
productID := "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
s := &Server{
testGetJob: func(_ context.Context, companyID, id uuid.UUID) (processing.Job, error) {
if companyID != cid || id != jobID {
@@ -357,7 +356,7 @@ func TestHandleV1GetProcessCompletedIncludesProcessedItems(t *testing.T) {
}
return []processing.V1ProcessJobItem{{
"ean": "0123456789012",
"id": productID,
"id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"status": "processed",
"title": "Acme Widget",
}}, nil
@@ -393,9 +392,12 @@ func TestHandleV1GetProcessCompletedIncludesProcessedItems(t *testing.T) {
if !ok {
t.Fatalf("item=%T", items[0])
}
if item["status"] != "processed" || item["id"] != productID || item["ean"] != "0123456789012" {
if item["status"] != "processed" || item["ean"] != "0123456789012" {
t.Fatalf("item=%v", item)
}
if _, ok := item["id"]; ok {
t.Fatalf("id must be omitted from public process items: %v", item)
}
if _, ok := body.Data["processed_at"]; !ok {
t.Fatalf("missing processed_at: %v", body.Data)
}
@@ -447,6 +449,90 @@ func TestHandleGetProcessingJobCompletedIncludesItems(t *testing.T) {
}
}
func TestHandleV1GetProcessJobOmitsAIEnhance(t *testing.T) {
t.Parallel()
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
jobID := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
note := "GPT: rewrite title with --- Title --- markers"
errMsg := "openai: invalid api-key sk-live-secret"
s := &Server{
testGetJob: func(_ context.Context, companyID, id uuid.UUID) (processing.Job, error) {
return processing.Job{
ID: jobID,
CompanyID: companyID,
Status: "processing",
ProcessingType: "full",
CurrentStep: "ai_enhance",
StepProgress: []processing.StepProgress{{
Step: "ai_enhance", Status: "running", Note: note,
}},
Error: &errMsg,
TotalProducts: 2,
ProcessedProducts: 1,
}, nil
},
}
ctx := context.WithValue(context.Background(), ctxCompanyID, cid)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/process/"+jobID.String(), nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", jobID.String())
req = req.WithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx))
s.handleV1GetProcessJob(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
raw := rec.Body.String()
if strings.Contains(raw, "ai_enhance") {
t.Fatalf("public process poll leaked ai_enhance: %s", raw)
}
if strings.Contains(raw, "GPT:") || strings.Contains(raw, "--- Title ---") {
t.Fatalf("public process poll leaked step notes: %s", raw)
}
if strings.Contains(raw, "current_step") || strings.Contains(raw, "step_progress") || strings.Contains(raw, "company_id") {
t.Fatalf("public process poll leaked internals: %s", raw)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["status"] != "processing" {
t.Fatalf("status=%v", body["status"])
}
if body["total_products"].(float64) != 2 || body["processed_products"].(float64) != 1 {
t.Fatalf("counts=%v", body)
}
}
func TestHandleGetProcessingJobKeepsCurrentStep(t *testing.T) {
t.Parallel()
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
jobID := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
s := &Server{
testGetJob: func(_ context.Context, companyID, id uuid.UUID) (processing.Job, error) {
return processing.Job{
ID: jobID,
CompanyID: companyID,
Status: "processing",
CurrentStep: "ai_enhance",
}, nil
},
}
ctx := context.WithValue(context.Background(), ctxCompanyID, cid)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/processing/jobs/"+jobID.String(), nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", jobID.String())
req = req.WithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx))
s.handleGetProcessingJob(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "ai_enhance") {
t.Fatalf("dashboard GET should keep current_step: %s", rec.Body.String())
}
}
func TestHandleV1ListProcessJobsUnauthorizedWithoutCompany(t *testing.T) {
t.Parallel()
s := &Server{}
@@ -500,3 +586,126 @@ func TestAssertV1ProcessGatesNilBilling(t *testing.T) {
t.Fatalf("nil billing must no-op: %v", err)
}
}
func processJobWithSEOMetaItems(cid, jobID uuid.UUID) *Server {
return &Server{
testGetJob: func(_ context.Context, companyID, id uuid.UUID) (processing.Job, error) {
return processing.Job{
ID: jobID,
CompanyID: companyID,
Status: "completed",
ProcessingType: "full",
TotalProducts: 1,
}, nil
},
testLoadV1ProcessJobItems: func(context.Context, uuid.UUID, uuid.UUID, string) ([]processing.V1ProcessJobItem, error) {
return []processing.V1ProcessJobItem{{
"ean": "0123456789012",
"status": "processed",
"name": "Acme Widget",
"meta_title": "Acme | Widget",
"meta_description": "A widget for tests",
}}, nil
},
}
}
func getProcessJSONItem(t *testing.T, rec *httptest.ResponseRecorder, wrapped bool) map[string]any {
t.Helper()
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var raw map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil {
t.Fatal(err)
}
data := raw
if wrapped {
var ok bool
data, ok = raw["data"].(map[string]any)
if !ok {
t.Fatalf("data=%T body=%s", raw["data"], rec.Body.String())
}
}
items, ok := data["items"].([]any)
if !ok || len(items) != 1 {
t.Fatalf("items=%v", data["items"])
}
item, ok := items[0].(map[string]any)
if !ok {
t.Fatalf("item=%T", items[0])
}
return item
}
func TestA1ProcessEndpointsOmitSEOMeta(t *testing.T) {
t.Parallel()
cid := uuid.MustParse("604f23a8-b66e-4b21-8b45-0d72b68f4790")
jobID := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
s := processJobWithSEOMetaItems(cid, jobID)
ctx := context.WithValue(context.Background(), ctxCompanyID, cid)
legacyRec := httptest.NewRecorder()
legacyReq := httptest.NewRequest(http.MethodGet, "/api/v1/products/process/"+jobID.String(), nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", jobID.String())
legacyReq = legacyReq.WithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx))
s.handleV1GetProcess(legacyRec, legacyReq)
legacyItem := getProcessJSONItem(t, legacyRec, true)
if _, ok := legacyItem["meta_title"]; ok {
t.Fatalf("GET /products/process/{id} A1 meta_title=%v", legacyItem["meta_title"])
}
if _, ok := legacyItem["meta_description"]; ok {
t.Fatalf("GET /products/process/{id} A1 meta_description=%v", legacyItem["meta_description"])
}
jobRec := httptest.NewRecorder()
jobReq := httptest.NewRequest(http.MethodGet, "/api/v1/process/"+jobID.String(), nil)
jrctx := chi.NewRouteContext()
jrctx.URLParams.Add("id", jobID.String())
jobReq = jobReq.WithContext(context.WithValue(ctx, chi.RouteCtxKey, jrctx))
s.handleGetProcessingJob(jobRec, jobReq)
jobItem := getProcessJSONItem(t, jobRec, false)
if _, ok := jobItem["meta_title"]; ok {
t.Fatalf("GET /process/{id} A1 meta_title=%v", jobItem["meta_title"])
}
if _, ok := jobItem["meta_description"]; ok {
t.Fatalf("GET /process/{id} A1 meta_description=%v", jobItem["meta_description"])
}
}
func TestNonA1ProcessEndpointsKeepSEOMeta(t *testing.T) {
t.Parallel()
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
jobID := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
s := processJobWithSEOMetaItems(cid, jobID)
ctx := context.WithValue(context.Background(), ctxCompanyID, cid)
legacyRec := httptest.NewRecorder()
legacyReq := httptest.NewRequest(http.MethodGet, "/api/v1/products/process/"+jobID.String(), nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", jobID.String())
legacyReq = legacyReq.WithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx))
s.handleV1GetProcess(legacyRec, legacyReq)
legacyItem := getProcessJSONItem(t, legacyRec, true)
if legacyItem["meta_title"] != "Acme | Widget" {
t.Fatalf("non-A1 GET /products/process/{id} meta_title=%v", legacyItem["meta_title"])
}
if legacyItem["meta_description"] != "A widget for tests" {
t.Fatalf("non-A1 GET /products/process/{id} meta_description=%v", legacyItem["meta_description"])
}
jobRec := httptest.NewRecorder()
jobReq := httptest.NewRequest(http.MethodGet, "/api/v1/process/"+jobID.String(), nil)
jrctx := chi.NewRouteContext()
jrctx.URLParams.Add("id", jobID.String())
jobReq = jobReq.WithContext(context.WithValue(ctx, chi.RouteCtxKey, jrctx))
s.handleGetProcessingJob(jobRec, jobReq)
jobItem := getProcessJSONItem(t, jobRec, false)
if jobItem["meta_title"] != "Acme | Widget" {
t.Fatalf("non-A1 GET /process/{id} meta_title=%v", jobItem["meta_title"])
}
if jobItem["meta_description"] != "A widget for tests" {
t.Fatalf("non-A1 GET /process/{id} meta_description=%v", jobItem["meta_description"])
}
}
+14 -11
View File
@@ -2,6 +2,7 @@ package mail
import (
"fmt"
"html"
"log"
"net"
"net/smtp"
@@ -109,31 +110,33 @@ func hasHeaderBreak(v string) bool {
func InviteMessage(webOrigin, email, token, companyName string) Message {
link := AcceptInviteURL(webOrigin, token)
text := fmt.Sprintf("You have been invited to %s on Descrybe.\n\nAccept: %s\n", companyName, link)
html := fmt.Sprintf(
htmlBody := fmt.Sprintf(
`<p>You have been invited to <strong>%s</strong> on Descrybe.</p><p><a href="%s">Accept invite</a></p>`,
companyName, link,
html.EscapeString(companyName), html.EscapeString(link),
)
return Message{To: email, Subject: "You are invited to Descrybe", Text: text, HTML: html}
return Message{To: email, Subject: "You are invited to Descrybe", Text: text, HTML: htmlBody}
}
// AcceptInviteURL builds the durable invite / set-password accept link (hashed invite tokens).
// Token is placed in the URL fragment so it is not sent on the page GET (Referer/access logs).
func AcceptInviteURL(webOrigin, token string) string {
return strings.TrimRight(webOrigin, "/") + "/accept-invite?token=" + token
return strings.TrimRight(webOrigin, "/") + "/accept-invite#token=" + token
}
// SetPasswordURL builds the HMAC set-password accept-invite link.
// Token is placed in the URL fragment so it is not sent on the page GET (Referer/access logs).
func SetPasswordURL(webOrigin, token string) string {
return strings.TrimRight(webOrigin, "/") + "/accept-invite?token=" + token + "&mode=set-password"
return strings.TrimRight(webOrigin, "/") + "/accept-invite#token=" + token + "&mode=set-password"
}
func SetPasswordMessage(webOrigin, email, token string) Message {
link := SetPasswordURL(webOrigin, token)
text := fmt.Sprintf("Set your Descrybe password:\n\n%s\n\nThis link expires in 72 hours.\n", link)
html := fmt.Sprintf(
htmlBody := fmt.Sprintf(
`<p>Set your Descrybe password:</p><p><a href="%s">Set password</a></p><p>This link expires in 72 hours.</p>`,
link,
html.EscapeString(link),
)
return Message{To: email, Subject: "Set your Descrybe password", Text: text, HTML: html}
return Message{To: email, Subject: "Set your Descrybe password", Text: text, HTML: htmlBody}
}
// MigratedSetPasswordMessage uses migrator invite tokens (accept-invite flow).
@@ -143,11 +146,11 @@ func MigratedSetPasswordMessage(webOrigin, email, token string) Message {
"Your Descrybe account was migrated. Set your password here:\n\n%s\n\nIf you did not expect this email, ignore it.\n",
link,
)
html := fmt.Sprintf(
htmlBody := fmt.Sprintf(
`<p>Your Descrybe account was migrated.</p><p><a href="%s">Set your password</a></p><p>If you did not expect this email, ignore it.</p>`,
link,
html.EscapeString(link),
)
return Message{To: email, Subject: "Set your Descrybe password", Text: text, HTML: html}
return Message{To: email, Subject: "Set your Descrybe password", Text: text, HTML: htmlBody}
}
// ResetPasswordURL builds the self-serve forgot-password reset link.
+32 -1
View File
@@ -118,9 +118,32 @@ func TestNewDynamicResolvesPerCall(t *testing.T) {
}
}
func TestInviteAndSetPasswordMessagesEscapeHTML(t *testing.T) {
name := `Acme <script>alert("x")</script> & Co`
msg := InviteMessage("https://app.example", "a@b.c", "tok", name)
if strings.Contains(msg.HTML, "<script>") {
t.Fatalf("unescaped script in HTML: %s", msg.HTML)
}
if !strings.Contains(msg.HTML, "Acme &lt;script&gt;") || !strings.Contains(msg.HTML, "&amp; Co") {
t.Fatalf("expected escaped company name, got %s", msg.HTML)
}
if !strings.Contains(msg.Text, name) {
t.Fatalf("text should keep company name: %s", msg.Text)
}
set := SetPasswordMessage("https://app.example", "a@b.c", `tok"onclick="alert(1)`)
if strings.Contains(set.HTML, `"onclick=`) {
t.Fatalf("unescaped token in set-password HTML: %s", set.HTML)
}
mig := MigratedSetPasswordMessage("https://app.example", "a@b.c", `tok"><img src=x>`)
if strings.Contains(mig.HTML, "<img") {
t.Fatalf("unescaped token in migrated HTML: %s", mig.HTML)
}
}
func TestSetPasswordURL(t *testing.T) {
got := SetPasswordURL("http://localhost:5174/", "tok123")
want := "http://localhost:5174/accept-invite?token=tok123&mode=set-password"
want := "http://localhost:5174/accept-invite#token=tok123&mode=set-password"
if got != want {
t.Fatalf("SetPasswordURL=%q want %q", got, want)
}
@@ -129,3 +152,11 @@ func TestSetPasswordURL(t *testing.T) {
t.Fatalf("SetPasswordMessage text missing link: %q", msg.Text)
}
}
func TestAcceptInviteURLUsesFragment(t *testing.T) {
got := AcceptInviteURL("http://localhost:5174/", "tok123")
want := "http://localhost:5174/accept-invite#token=tok123"
if got != want {
t.Fatalf("AcceptInviteURL=%q want %q", got, want)
}
}
+17 -2
View File
@@ -185,11 +185,13 @@ func Handler() http.Handler {
}
// Gate restricts Prometheus scrapes in production: allow when metricsPublic is true
// (METRICS_PUBLIC=1) or the peer is loopback. Non-production always allows (local scrapes).
// (METRICS_PUBLIC=1) or the immediate TCP peer is loopback. Non-production always
// allows (local scrapes). Loopback is ignored when client-IP proxy headers are
// present so TrustedRealIP cannot mint 127.0.0.1 from X-Forwarded-For.
func Gate(isProduction, metricsPublic bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !isProduction || metricsPublic || isLoopbackRemoteAddr(r.RemoteAddr) {
if !isProduction || metricsPublic || allowLoopbackMetricsPeer(r) {
next.ServeHTTP(w, r)
return
}
@@ -198,6 +200,19 @@ func Gate(isProduction, metricsPublic bool) func(http.Handler) http.Handler {
}
}
func allowLoopbackMetricsPeer(r *http.Request) bool {
if forwardedClientIPPresent(r) {
return false
}
return isLoopbackRemoteAddr(r.RemoteAddr)
}
func forwardedClientIPPresent(r *http.Request) bool {
return strings.TrimSpace(r.Header.Get("X-Forwarded-For")) != "" ||
strings.TrimSpace(r.Header.Get("X-Real-IP")) != "" ||
strings.TrimSpace(r.Header.Get("True-Client-IP")) != ""
}
func isLoopbackRemoteAddr(remoteAddr string) bool {
host := strings.TrimSpace(remoteAddr)
if host == "" {
+14
View File
@@ -134,3 +134,17 @@ func TestGateAllowsPublicFlagInProduction(t *testing.T) {
t.Fatalf("METRICS_PUBLIC status=%d", rec.Code)
}
}
func TestGateBlocksSpoofedLoopbackXFFInProduction(t *testing.T) {
t.Cleanup(Reset)
Reset()
h := Gate(true, false)(Handler())
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
req.RemoteAddr = "127.0.0.1:54321"
req.Header.Set("X-Forwarded-For", "127.0.0.1")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("spoofed XFF loopback status=%d want 404", rec.Code)
}
}
+94 -7
View File
@@ -67,11 +67,17 @@ func truncateRunes(s string, max int) string {
return s
}
const (
publicErrProcessingFailed = "processing_failed"
publicErrProviderUnavailable = "provider_unavailable"
)
// TruncateError returns a safe, short error string for DB storage / API clients.
// Secret-like substrings and logredact matches become an opaque message so
// job step_progress notes and v1 item errors cannot leak keys/JWTs/DSNs/emails.
// Common provider transport failures are rewritten to short user-facing text
// (no dial URLs / Go net strings) while preserving unrelated provider messages.
// (no dial URLs / Go net strings). Remaining provider internals map to stable
// public codes (processing_failed / provider_unavailable).
func TruncateError(err error) string {
if err == nil {
return ""
@@ -94,20 +100,101 @@ func TruncateError(err error) string {
return friendly
}
// Drop retry-exhaustion wrapper when the inner message is already clear.
cleaned := redacted
cleaned := stripOpenAIRetryPrefix(redacted)
if friendly := classifyProviderError(cleaned); friendly != "" {
return friendly
}
if code := mapPublicErrorCode(cleaned); code != "" {
return code
}
return truncateRunes(cleaned, 500)
}
// PublicV1Error maps a stored job/item error to a stable public v1 string.
// Dashboard notes / step_progress may still carry operator detail; public JSON must not.
func PublicV1Error(msg string) string {
msg = strings.TrimSpace(msg)
if msg == "" {
return ""
}
switch msg {
case publicErrProcessingFailed, publicErrProviderUnavailable,
"Product data not available", "Processing failed",
"provider error (details redacted)":
return msg
}
if strings.HasPrefix(msg, "processing.job.error.") {
return msg
}
if strings.Contains(strings.ToLower(msg), "insufficient credits") {
return msg
}
if friendly := classifyProviderError(msg); friendly != "" {
return friendly
}
cleaned := stripOpenAIRetryPrefix(msg)
if friendly := classifyProviderError(cleaned); friendly != "" {
return friendly
}
if code := mapPublicErrorCode(cleaned); code != "" {
return code
}
if looksLikeInternalProviderError(cleaned) {
return publicErrProcessingFailed
}
return truncateRunes(cleaned, 500)
}
func stripOpenAIRetryPrefix(msg string) string {
cleaned := msg
for _, prefix := range []string{
"openai retries exhausted: ",
"openai embedding retries exhausted: ",
} {
if strings.HasPrefix(strings.ToLower(cleaned), prefix) {
cleaned = strings.TrimSpace(cleaned[len(prefix):])
break
return strings.TrimSpace(cleaned[len(prefix):])
}
}
if friendly := classifyProviderError(cleaned); friendly != "" {
return friendly
return cleaned
}
func mapPublicErrorCode(msg string) string {
lower := strings.ToLower(strings.TrimSpace(msg))
if lower == "" {
return ""
}
return truncateRunes(cleaned, 500)
switch {
case strings.Contains(lower, "max_tokens"),
strings.Contains(lower, "length-capped"):
return publicErrProcessingFailed
case strings.Contains(lower, "openai unset"),
strings.Contains(lower, "platform openai"):
return publicErrProviderUnavailable
case strings.Contains(lower, "ai_enhance: skipped"):
return publicErrProcessingFailed
case strings.Contains(lower, "unavailable"),
strings.Contains(lower, "http 503"),
strings.Contains(lower, "overloaded"):
return publicErrProviderUnavailable
case strings.Contains(lower, "openai"),
strings.Contains(lower, "gpt-"),
strings.Contains(lower, " for model"):
return publicErrProcessingFailed
}
return ""
}
func looksLikeInternalProviderError(msg string) bool {
lower := strings.ToLower(msg)
for _, n := range []string{
"openai", "max_tokens", "length-capped", "gpt-", "ai_enhance",
"finish_reason", "chat/completions",
} {
if strings.Contains(lower, n) {
return true
}
}
return false
}
// classifyProviderError maps common OpenAI-compatible transport/auth failures
+61 -4
View File
@@ -49,6 +49,65 @@ func TestTruncateError_redactsSecrets(t *testing.T) {
}
}
func TestTruncateError_mapsInternalProviderLeaks(t *testing.T) {
cases := []struct {
in string
want string
}{
{
in: `openai length-capped at max_tokens=16000 for model "gpt-5.4" (prefer a faster non-reasoning product-enhance model): unexpected EOF`,
want: "processing_failed",
},
{
in: "ai_enhance: skipped (platform OpenAI unset; configure admin settings or company BYOK)",
want: "provider_unavailable",
},
{
in: "openai retries exhausted: green-chat unavailable",
want: "provider_unavailable",
},
{
in: "upstream 503: model overloaded",
want: "provider_unavailable",
},
}
for _, tc := range cases {
got := TruncateError(errString(tc.in))
if got != tc.want {
t.Fatalf("TruncateError(%q)=%q want %q", tc.in, got, tc.want)
}
if strings.Contains(strings.ToLower(got), "openai") ||
strings.Contains(strings.ToLower(got), "max_tokens") ||
strings.Contains(got, "gpt-") {
t.Fatalf("internal leak in TruncateError: %q → %q", tc.in, got)
}
}
}
func TestPublicV1Error_stripsProviderInternals(t *testing.T) {
cases := []struct {
in string
want string
}{
{in: `openai length-capped at max_tokens=8000 for model "gpt-4o"`, want: "processing_failed"},
{in: "ai_enhance: skipped (platform OpenAI unset; configure admin settings or company BYOK)", want: "provider_unavailable"},
{in: "processing_failed", want: "processing_failed"},
{in: "Product data not available", want: "Product data not available"},
{in: "processing.job.error.all_failed|count=3", want: "processing.job.error.all_failed|count=3"},
{in: "insufficient credits", want: "insufficient credits"},
}
for _, tc := range cases {
got := PublicV1Error(tc.in)
if got != tc.want {
t.Fatalf("PublicV1Error(%q)=%q want %q", tc.in, got, tc.want)
}
lower := strings.ToLower(got)
if strings.Contains(lower, "openai") || strings.Contains(lower, "max_tokens") {
t.Fatalf("public v1 leak: %q → %q", tc.in, got)
}
}
}
func TestTruncateError_classifiesProviderFailures(t *testing.T) {
cases := []struct {
in string
@@ -68,10 +127,8 @@ in: "unauthorized", want: "AI provider rejected the API key"},
in: "openai http 401", want: "AI provider rejected the API key"},
{in: "rate limited or server error", want: "AI provider temporarily unavailable"},
{in: "too many requests", want: "AI provider rate limited"},
{
in: "upstream 503: model overloaded", want: "upstream 503: model overloaded"},
{
in: "openai retries exhausted: green-chat unavailable", want: "green-chat unavailable"},
{in: "upstream 503: model overloaded", want: "provider_unavailable"},
{in: "openai retries exhausted: green-chat unavailable", want: "provider_unavailable"},
}
for _, tc := range cases {
got := TruncateError(errString(tc.in))
@@ -46,6 +46,7 @@ func TestV1ProcessJobItemMarshalJSON_order(t *testing.T) {
"category": "Hladilniki",
"category_id": "11",
"processed_product_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"gpt_response": "should-not-ship",
"status": "processed",
}
b, err := item.MarshalJSON()
@@ -56,11 +57,15 @@ func TestV1ProcessJobItemMarshalJSON_order(t *testing.T) {
eanAt := strings.Index(s, `"ean"`)
titleAt := strings.Index(s, `"title"`)
attrsAt := strings.Index(s, `"attributes"`)
ppAt := strings.Index(s, `"processed_product_id"`)
if eanAt < 0 || titleAt < 0 || attrsAt < 0 || ppAt < 0 {
if eanAt < 0 || titleAt < 0 || attrsAt < 0 {
t.Fatalf("missing keys in %s", s)
}
if !(eanAt < titleAt && titleAt < attrsAt && attrsAt < ppAt) {
if !(eanAt < titleAt && titleAt < attrsAt) {
t.Fatalf("bad key order in %s", s)
}
for _, leak := range []string{`"processed_product_id"`, `"gpt_response"`} {
if strings.Contains(s, leak) {
t.Fatalf("allowlist MarshalJSON must omit %s: %s", leak, s)
}
}
}
+32 -34
View File
@@ -5,7 +5,7 @@ import (
"encoding/json"
)
// v1ProcessItemKeyOrder is the human-readable LegacyProcessItem property order.
// v1ProcessItemKeyOrder is the human-readable ProcessItem property order.
// Important catalog fields first; internal ids / SEO last.
var v1ProcessItemKeyOrder = []string{
"ean",
@@ -23,13 +23,39 @@ var v1ProcessItemKeyOrder = []string{
"error",
"meta_title",
"meta_description",
"id",
"processed_product_id",
"raw_product_id",
}
// MarshalJSON emits LegacyProcessItem keys in a stable human-readable order.
// ASSUMPTION: JSON object key order is part of the V1 readability contract for A1.
// v1ProcessItemInternalKeys must never appear on public ProcessItem JSON.
var v1ProcessItemInternalKeys = map[string]struct{}{
"id": {},
"processed_product_id": {},
"raw_product_id": {},
"field_sources": {},
"enhance_input_hash": {},
"ai_enhance": {},
"finish_reason": {},
"gpt_response": {},
"total_tokens": {},
"prompt_tokens": {},
"completion_tokens": {},
"ai_provider_mode": {},
"notes": {},
"worker": {},
"model": {},
"provider": {},
}
func stripV1ProcessItemInternalKeys(item V1ProcessJobItem) {
if item == nil {
return
}
for k := range v1ProcessItemInternalKeys {
delete(item, k)
}
}
// MarshalJSON emits only the public ProcessItem allowlist in a stable order.
// Unexpected keys (including pipeline internals) cannot ship.
func (item V1ProcessJobItem) MarshalJSON() ([]byte, error) {
if item == nil {
return []byte("null"), nil
@@ -55,43 +81,15 @@ func (item V1ProcessJobItem) MarshalJSON() ([]byte, error) {
buf.Write(vb)
return nil
}
seen := map[string]struct{}{}
for _, k := range v1ProcessItemKeyOrder {
v, ok := item[k]
if !ok {
continue
}
seen[k] = struct{}{}
if err := writePair(k, v); err != nil {
return nil, err
}
}
// Preserve any unexpected keys deterministically (sorted via encoding/json map).
extras := map[string]any{}
for k, v := range item {
if _, ok := seen[k]; ok {
continue
}
extras[k] = v
}
if len(extras) > 0 {
eb, err := json.Marshal(extras)
if err != nil {
return nil, err
}
// eb is `{...}`; splice inner pairs.
inner := bytes.TrimSpace(eb)
if len(inner) >= 2 && inner[0] == '{' && inner[len(inner)-1] == '}' {
inner = inner[1 : len(inner)-1]
if len(bytes.TrimSpace(inner)) > 0 {
if !first {
buf.WriteByte(',')
}
first = false
buf.Write(inner)
}
}
}
buf.WriteByte('}')
return buf.Bytes(), nil
}
+82 -13
View File
@@ -13,6 +13,7 @@ import (
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
"github.com/descrybe/descrybe-v2/apps/api/internal/eprel"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
var v1PartialSteps = []string{"category", "title", "description", "attributes"}
@@ -274,7 +275,7 @@ func (p *Pipeline) LoadV1ProcessJobItems(ctx context.Context, companyID, jobID u
"error": "Product data not available",
}
if itemError != nil && *itemError != "" {
item["error"] = *itemError
item["error"] = PublicV1Error(*itemError)
}
full = append(full, item)
continue
@@ -309,6 +310,9 @@ func (p *Pipeline) LoadV1ProcessJobItems(ctx context.Context, companyID, jobID u
if descTxt != nil {
descOut = v1PreserveDescription(*descTxt)
}
if isPromptLeakageTitle(descOut) || isPromptLabelTitle(descOut) {
descOut = ""
}
eprelVal := extractEPRELFromAttrs(attrs)
attrs = SanitizeV1ProcessAttributesAllowed(attrs, allowedAttrs)
@@ -453,7 +457,7 @@ func (p *Pipeline) LoadV1ProcessJobItems(ctx context.Context, companyID, jobID u
// Internal ids (id / processed_product_id / raw_product_id) are omitted from
// the public V1 process item shape — use catalog APIs when a UUID is needed.
if itemError != nil && *itemError != "" {
item["error"] = *itemError
item["error"] = PublicV1Error(*itemError)
}
if len(attrs) > 0 {
item["attributes"] = attrs
@@ -484,29 +488,66 @@ func nullIfEmptyPtr(s *string) any {
return *s
}
// companyOmitsSEOMeta is true when V1/process must omit meta_title / meta_description:
// A1 cohort (legacy id), Platform Demo (A1-cloned prompts, no legacy id), or any
// company whose categories store A1-style role-section enhance prompts.
func companyOmitsSEOMeta(ctx context.Context, p *Pipeline, companyID uuid.UUID) bool {
if p == nil || p.Pool == nil {
// Known v2 companies.id values that omit SEO meta on V1 process payloads.
// A1 Slovenija is also matched via billing.A1LegacyCompanyID (legacy_company_id / PK).
const (
a1V2CompanyID = "604f23a8-b66e-4b21-8b45-0d72b68f4790"
platformDemoV2CompanyID = "2b3159b0-fc08-415b-b248-35ed02a6baab"
)
// CompanyOmitsSEOMetaID is true for A1 Slovenija and Platform Demo by companies.id
// (or the migrated MySQL company id). Does not grant Legacy plan privileges.
func CompanyOmitsSEOMetaID(companyID uuid.UUID) bool {
if companyID == uuid.Nil {
return false
}
id := strings.ToLower(companyID.String())
switch id {
case a1V2CompanyID, platformDemoV2CompanyID, strings.ToLower(billing.A1LegacyCompanyID):
return true
}
return false
}
// CompanyOmitsSEOMetaLookup is the shared A1/Demo SEO-omit decision used by V1 process
// and catalog list/get/update. Matches hardcoded companies.id, A1 cohort (legacy id),
// Platform Demo by name, or A1-style --- Title --- / --- Description --- / --- Meta ---
// category prompts.
func CompanyOmitsSEOMetaLookup(companyID uuid.UUID, legacyID, name string, hasA1SectionPrompts bool) bool {
if CompanyOmitsSEOMetaID(companyID) {
return true
}
if billing.IsA1CohortCompany(legacyID, "") {
return true
}
if strings.EqualFold(strings.TrimSpace(name), "Platform Demo") {
return true
}
return hasA1SectionPrompts
}
// CompanyOmitsSEOMeta is true when catalog/V1 process must omit meta_title /
// meta_description for this company (same rules as CompanyOmitsSEOMetaLookup).
func CompanyOmitsSEOMeta(ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID) bool {
if CompanyOmitsSEOMetaID(companyID) {
return true
}
if pool == nil {
return false
}
var legacy, name string
err := p.Pool.QueryRow(ctx, `
err := pool.QueryRow(ctx, `
SELECT COALESCE(legacy_company_id, ''), COALESCE(name, '')
FROM companies
WHERE id = $1`, companyID).Scan(&legacy, &name)
if err != nil {
return false
}
if billing.IsA1CohortCompany(legacy, "") {
return true
}
if strings.EqualFold(strings.TrimSpace(name), "Platform Demo") {
if CompanyOmitsSEOMetaLookup(companyID, legacy, name, false) {
return true
}
var hasA1Prompts bool
err = p.Pool.QueryRow(ctx, `
err = pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM categories
WHERE company_id = $1
@@ -521,6 +562,34 @@ func companyOmitsSEOMeta(ctx context.Context, p *Pipeline, companyID uuid.UUID)
return hasA1Prompts
}
// StripV1SEOMeta removes meta_title / meta_description keys (omit, not null).
func StripV1SEOMeta(items []V1ProcessJobItem) []V1ProcessJobItem {
for _, item := range items {
if item == nil {
continue
}
delete(item, "meta_title")
delete(item, "meta_description")
}
return items
}
// ApplyV1SEOMetaPolicy omits SEO meta on A1/Demo process items by companies.id.
func ApplyV1SEOMetaPolicy(companyID uuid.UUID, items []V1ProcessJobItem) []V1ProcessJobItem {
if !CompanyOmitsSEOMetaID(companyID) {
return items
}
return StripV1SEOMeta(items)
}
// companyOmitsSEOMeta is the pipeline wrapper around CompanyOmitsSEOMeta.
func companyOmitsSEOMeta(ctx context.Context, p *Pipeline, companyID uuid.UUID) bool {
if p == nil {
return CompanyOmitsSEOMeta(ctx, nil, companyID)
}
return CompanyOmitsSEOMeta(ctx, p.Pool, companyID)
}
func derefStringPtr(s *string) string {
if s == nil {
return ""
@@ -190,6 +190,37 @@ func TestApplyV1ProcessItemIDsDualMode(t *testing.T) {
}
}
func TestV1ProcessJobItemMarshalJSONOmitsInternals(t *testing.T) {
item := V1ProcessJobItem{
"ean": "1",
"status": "processed",
"name": "T",
"id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"ai_enhance": true,
"finish_reason": "stop",
"enhance_input_hash": "deadbeef",
"field_sources": map[string]any{"enhance_input_hash": "deadbeef"},
"prompt_tokens": 9,
"worker": "river",
}
raw, err := json.Marshal(item)
if err != nil {
t.Fatal(err)
}
s := string(raw)
for _, junk := range []string{
`"id"`, `"ai_enhance"`, `"finish_reason"`, `"enhance_input_hash"`,
`"field_sources"`, `"prompt_tokens"`, `"worker"`, "deadbeef",
} {
if strings.Contains(s, junk) {
t.Fatalf("internal %q leaked in JSON: %s", junk, s)
}
}
if !strings.Contains(s, `"ean":"1"`) || !strings.Contains(s, `"name":"T"`) {
t.Fatalf("public fields missing: %s", s)
}
}
func TestFormatJobStatusResponseAddsItems(t *testing.T) {
jobID := mustParseTestUUID(t, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
job := Job{ID: jobID, Status: "completed", TotalProducts: 1}
@@ -218,3 +249,33 @@ func mustParseTestUUID(t *testing.T, s string) uuid.UUID {
}
return id
}
func TestCompanyOmitsSEOMetaLookup(t *testing.T) {
t.Parallel()
a1 := mustParseTestUUID(t, "604f23a8-b66e-4b21-8b45-0d72b68f4790")
other := mustParseTestUUID(t, "11111111-1111-1111-1111-111111111111")
cases := []struct {
name string
id uuid.UUID
legacy string
coName string
prompts bool
wantOmit bool
}{
{name: "ordinary", id: other, coName: "Acme", wantOmit: false},
{name: "a1_uuid", id: a1, wantOmit: true},
{name: "demo_by_name", id: other, coName: "Platform Demo", wantOmit: true},
{name: "demo_by_name_case", id: other, coName: " platform DEMO ", wantOmit: true},
{name: "a1_prompt_markers", id: other, coName: "Acme", prompts: true, wantOmit: true},
{name: "a1_legacy_id", id: other, legacy: "97e1a309-3d23-4aa2-b518-8e8d7afdfec7", wantOmit: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := CompanyOmitsSEOMetaLookup(tc.id, tc.legacy, tc.coName, tc.prompts)
if got != tc.wantOmit {
t.Fatalf("got %v want %v", got, tc.wantOmit)
}
})
}
}
@@ -9,7 +9,7 @@ import (
)
// V1ProcessItemScorecard scores one COMPLETED legacy process item against
// OpenAPI LegacyProcessItem + A1 poll expectations.
// OpenAPI ProcessItem + A1 poll expectations.
type V1ProcessItemScorecard struct {
EAN string `json:"ean"`
Status string `json:"status"`
@@ -184,7 +184,7 @@ func ScoreV1ProcessCompletedItem(item V1ProcessJobItem, opts ScoreV1ProcessItemO
return sc
}
// EnforceV1ProcessCompletedItem fills LegacyProcessItem projection gaps for a successful item:
// EnforceV1ProcessCompletedItem fills ProcessItem projection gaps for a successful item:
// nonempty description when title exists (formula HTML preserved), readable title spacing,
// category display name as primary, optional SEO meta (unless omitSEOMeta), clean attrs.
// allowed is the company attribute_key set (canonicalized). When nil, only
@@ -209,6 +209,7 @@ func EnforceV1ProcessCompletedItemOpts(item V1ProcessJobItem, opts EnforceV1Opts
if item == nil {
return item
}
stripV1ProcessItemInternalKeys(item)
status, _ := item["status"].(string)
if status != "processed" {
return item
@@ -230,6 +231,9 @@ func EnforceV1ProcessCompletedItemOpts(item V1ProcessJobItem, opts EnforceV1Opts
if title == "" {
title = ensureReadableTitleSpacing(stringFromItem(item, "title"))
}
if isPromptLabelTitle(title) {
title = preferredProductTitle(stringFromItem(item, "ean"), title)
}
if title != "" {
item["name"] = title
delete(item, "title")
@@ -237,9 +241,6 @@ func EnforceV1ProcessCompletedItemOpts(item V1ProcessJobItem, opts EnforceV1Opts
item["name"] = nil
delete(item, "title")
}
delete(item, "id")
delete(item, "processed_product_id")
delete(item, "raw_product_id")
catID, catName := projectV1CategoryFields(item)
catLabel := catName
@@ -248,6 +249,9 @@ func EnforceV1ProcessCompletedItemOpts(item V1ProcessJobItem, opts EnforceV1Opts
}
desc, _ := descriptionFromItem(item)
if isPromptLeakageTitle(desc) || isPromptLabelTitle(desc) {
desc = ""
}
needsDesc := title != "" && (desc == "" ||
isWeakPriorEnhanceDescription(desc, title) ||
descriptionEchoesTitle(desc, title) ||
@@ -4,6 +4,9 @@ import (
"fmt"
"strings"
"testing"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/google/uuid"
)
func TestScoreV1ProcessCompletedItem_processedStructure(t *testing.T) {
@@ -149,11 +152,25 @@ func TestEnforceV1ProcessCompletedItem_fillsDescriptionMetaSanitizes(t *testing.
func TestEnforceV1ProcessCompletedItem_skipsTerminalStatuses(t *testing.T) {
t.Parallel()
item := V1ProcessJobItem{"ean": "1", "status": "not_found", "error": "missing"}
item := V1ProcessJobItem{
"ean": "1",
"status": "not_found",
"error": "missing",
"ai_enhance": true,
"finish_reason": "stop",
"field_sources": map[string]any{"enhance_input_hash": "abc"},
"prompt_tokens": 12,
"id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
}
out := EnforceV1ProcessCompletedItem(item, "", nil)
if _, ok := out["title"]; ok {
t.Fatalf("should not invent fields for not_found: %v", out)
}
for _, junk := range []string{"id", "ai_enhance", "finish_reason", "field_sources", "prompt_tokens"} {
if _, ok := out[junk]; ok {
t.Fatalf("%s must be stripped from public items: %v", junk, out)
}
}
}
func TestEnforceV1ProcessCompletedItem_categoryFromCallerPreserved(t *testing.T) {
@@ -328,6 +345,119 @@ func TestEnforceV1ProcessCompletedItem_refreshesPromptLeakageMeta(t *testing.T)
}
}
func TestCompanyOmitsSEOMetaID(t *testing.T) {
t.Parallel()
a1 := uuid.MustParse("604f23a8-b66e-4b21-8b45-0d72b68f4790")
demo := uuid.MustParse("2b3159b0-fc08-415b-b248-35ed02a6baab")
legacy := uuid.MustParse(billing.A1LegacyCompanyID)
other := uuid.MustParse("11111111-1111-1111-1111-111111111111")
if !CompanyOmitsSEOMetaID(a1) || !CompanyOmitsSEOMetaID(demo) || !CompanyOmitsSEOMetaID(legacy) {
t.Fatal("A1, Demo, and legacy A1 ids must omit SEO meta")
}
if CompanyOmitsSEOMetaID(other) || CompanyOmitsSEOMetaID(uuid.Nil) {
t.Fatal("unrelated company ids must keep SEO meta")
}
}
func TestApplyV1SEOMetaPolicy_omitsOnlyA1(t *testing.T) {
t.Parallel()
item := V1ProcessJobItem{
"ean": "1",
"status": "processed",
"meta_title": "T",
"meta_description": "D",
}
a1 := uuid.MustParse("604f23a8-b66e-4b21-8b45-0d72b68f4790")
out := ApplyV1SEOMetaPolicy(a1, []V1ProcessJobItem{item})
if _, ok := out[0]["meta_title"]; ok {
t.Fatal("A1 must omit meta_title")
}
if _, ok := out[0]["meta_description"]; ok {
t.Fatal("A1 must omit meta_description")
}
keep := V1ProcessJobItem{
"ean": "1",
"status": "processed",
"meta_title": "T",
"meta_description": "D",
}
other := uuid.MustParse("11111111-1111-1111-1111-111111111111")
kept := ApplyV1SEOMetaPolicy(other, []V1ProcessJobItem{keep})
if kept[0]["meta_title"] != "T" || kept[0]["meta_description"] != "D" {
t.Fatalf("non-A1 must keep SEO meta: %v", kept[0])
}
}
func TestEnforceV1ProcessCompletedItemOpts_omitSEOMeta(t *testing.T) {
t.Parallel()
item := V1ProcessJobItem{
"ean": "1",
"status": "processed",
"title": "Sony Headphones",
"description": "Sony Headphones deliver clear audio with noise cancelling for daily commuting.",
"meta_title": "Sony | Headphones",
"meta_description": "Sony Headphones deliver clear audio.",
"category": "48",
"category_name": "Slušalke",
"attributes": map[string]any{"brand": "Sony"},
"eprel": nil,
}
out := EnforceV1ProcessCompletedItemOpts(item, EnforceV1Opts{Language: "sl", OmitSEOMeta: true})
if _, ok := out["meta_title"]; ok {
t.Fatalf("meta_title should be omitted: %v", out["meta_title"])
}
if _, ok := out["meta_description"]; ok {
t.Fatalf("meta_description should be omitted: %v", out["meta_description"])
}
sc := ScoreV1ProcessCompletedItem(out, ScoreV1ProcessItemOptions{MappedCategory: "48", OmitSEOMeta: true})
if !sc.OK {
t.Fatalf("omit meta should still score OK, flags=%v", sc.FailFlags)
}
}
func TestEnforceV1ProcessCompletedItemOpts_A1PollScrubsPromptDump(t *testing.T) {
t.Parallel()
item := V1ProcessJobItem{
"ean": "8606019604493",
"status": "processed",
"name": "--- Title ---\nReply with ONLY JSON\nSchema: {\"name\":\"string\"}",
"description": "--- Description ---\nReply with ONLY JSON (no markdown)\n" +
"Title formula: Brand + Model. Follow any constraints that follow.\n" +
"Schema: {\"description\":\"string\"}",
"meta_title": "Sony | short retail title; follow any Title formula constr…",
"meta_description": "prefer 1-3 factual paragraphs as ONE string",
"category": "50",
"category_name": "Cookers",
"attributes": map[string]any{"brand": "Vox", "product_model": "EHT6020WG"},
}
out := EnforceV1ProcessCompletedItemOpts(item, EnforceV1Opts{Language: "en", OmitSEOMeta: true})
name := fmt.Sprint(out["name"])
desc := fmt.Sprint(out["description"])
for _, phrase := range []string{
"--- Title ---",
"--- Description ---",
"Reply with ONLY JSON",
"Title formula",
"Schema:",
} {
if strings.Contains(name, phrase) || strings.Contains(desc, phrase) {
t.Fatalf("A1 poll must not return prompt dump %q: name=%q desc=%q", phrase, name, desc)
}
}
if isPromptLeakageTitle(name) || isPromptLeakageTitle(desc) {
t.Fatalf("A1 poll still looks like prompt leakage: name=%q desc=%q", name, desc)
}
if desc == "" || desc == "<nil>" || out["description"] == nil {
t.Fatalf("A1 poll should replace leaked description, got %v", out["description"])
}
if _, ok := out["meta_title"]; ok {
t.Fatal("A1 poll must omit meta_title")
}
if _, ok := out["meta_description"]; ok {
t.Fatal("A1 poll must omit meta_description")
}
}
func TestEnforceV1ProcessCompletedItem_preservesFormulaHTML(t *testing.T) {
t.Parallel()
htmlDesc := `<h1>Anker Soundcore Space One Pro</h1><p>Zložljive ANC slušalke z bogatim zvokom.</p><ul><li>Bluetooth 5.3</li></ul>`
+18 -18
View File
@@ -313,15 +313,15 @@ const GAPS = {
pl: "Członkowie zarządzają produktami i feedami. Administratorzy mogą też zapraszać współpracowników i zmieniać ustawienia firmy.",
ja: "メンバーは商品とフィードを管理します。管理者は同僚の招待と会社設定の変更もできます。"
},
"Demo sandbox is empty — switch to A1 or connect a feed to see real catalog stats.": {
es: "La zona de pruebas demo está vacía — cambia a A1 o conecta un feed para ver estadísticas reales del catálogo.",
fr: "Le bac à sable démo est vide — basculez vers A1 ou connectez un flux pour voir de vraies stats catalogue.",
de: "Demo-Sandbox ist leer — wechseln Sie zu A1 oder verbinden Sie einen Feed, um echte Katalogstatistiken zu sehen.",
it: "La sandbox demo è vuota — passa ad A1 o collega un feed per vedere statistiche reali del catalogo.",
pt: "A sandbox de demonstração está vazia — mude para A1 ou ligue um feed para ver estatísticas reais do catálogo.",
nl: "Demo-sandbox is leeg — schakel over naar A1 of koppel een feed om echte catalogusstatistieken te zien.",
pl: "Piaskownica demo jest pusta — przełącz na A1 lub podłącz feed, aby zobaczyć realne statystyki katalogu.",
ja: "デモサンドボックスは空です — A1に切り替えるかフィードを接続して実際のカタログ統計を表示します。"
"Demo sandbox is empty — connect a feed to see catalog stats.": {
es: "La zona de pruebas demo está vacía — conecta un feed para ver estadísticas reales del catálogo.",
fr: "Le bac à sable démo est vide — connectez un flux pour voir de vraies stats catalogue.",
de: "Demo-Sandbox ist leer — verbinden Sie einen Feed, um echte Katalogstatistiken zu sehen.",
it: "La sandbox demo è vuota — collega un feed per vedere statistiche reali del catalogo.",
pt: "A sandbox de demonstração está vazia — ligue um feed para ver estatísticas reais do catálogo.",
nl: "Demo-sandbox is leeg — koppel een feed om echte catalogusstatistieken te zien.",
pl: "Piaskownica demo jest pusta — podłącz feed, aby zobaczyć realne statystyki katalogu.",
ja: "デモサンドボックスは空です — フィードを接続して実際のカタログ統計を表示します。"
},
"Add a feed or upload a CSV to populate this workspace.": {
es: "Añade un feed o sube un CSV para poblar este espacio de trabajo.",
@@ -353,15 +353,15 @@ const GAPS = {
pl: "Importuj → wzbogacaj → publikuj. Przejdź do następnego kroku dla {name}.",
ja: "インポート → 強化 → 公開。{name} の次のステップへ。"
},
"Switch to A1 (or another seeded company) in the header, or connect a feed here to populate this sandbox.": {
es: "Cambia a A1 (u otra empresa con datos) en el encabezado, o conecta un feed aquí para poblar esta zona de pruebas.",
fr: "Basculez vers A1 (ou une autre entreprise seedée) dans l'en-tête, ou connectez un flux ici pour remplir ce bac à sable.",
de: "Wechseln Sie in der Kopfzeile zu A1 (oder einem anderen Seed-Unternehmen) oder verbinden Sie hier einen Feed, um diese Sandbox zu füllen.",
it: "Passa ad A1 (o un'altra azienda con dati) nell'intestazione, oppure collega un feed qui per popolare questa sandbox.",
pt: "Mude para A1 (ou outra empresa com dados) no cabeçalho, ou ligue um feed aqui para preencher esta sandbox.",
nl: "Schakel in de header over naar A1 (of een ander geseeded bedrijf), of koppel hier een feed om deze sandbox te vullen.",
pl: "Przełącz na A1 (lub inną firmę z danymi) w nagłówku albo podłącz tu feed, aby wypełnić tę piaskownicę.",
ja: "ヘッダーでA1(または別のシード済み会社)に切り替えるか、ここでフィードを接続してサンドボックスにデータを入れます。"
"Connect a feed here to populate this sandbox, or switch to another company in the header.": {
es: "Conecta un feed aquí para poblar esta zona de pruebas, o cambia a otra empresa en el encabezado.",
fr: "Connectez un flux ici pour remplir ce bac à sable, ou basculez vers une autre entreprise dans l'en-tête.",
de: "Verbinden Sie hier einen Feed, um diese Sandbox zu füllen, oder wechseln Sie in der Kopfzeile zu einem anderen Unternehmen.",
it: "Collega un feed qui per popolare questa sandbox, oppure passa a un'altra azienda nell'intestazione.",
pt: "Ligue um feed aqui para preencher esta sandbox, ou mude para outra empresa no cabeçalho.",
nl: "Koppel hier een feed om deze sandbox te vullen, of schakel in de header over naar een ander bedrijf.",
pl: "Podłącz tu feed, aby wypełnić tę piaskownicę, albo przełącz na inną firmę w nagłówku.",
ja: "ここでフィードを接続してサンドボックスにデータを入れるか、ヘッダーで別の会社に切り替えます。"
},
"Connect a feed or upload a CSV to start building your catalog.": {
es: "Conecta un feed o sube un CSV para empezar a crear tu catálogo.",
+2 -2
View File
@@ -172,11 +172,11 @@ export const EXTRA = {
"Los miembros gestionan productos y feeds. Los administradores también pueden invitar compañeros y cambiar la configuración de la empresa.",
"settings.sendInvite": "Enviar invitación",
"dashboard.demoEmptyHint":
"La zona de pruebas demo está vacía — cambia a A1 o conecta un feed para ver estadísticas reales del catálogo.",
"La zona de pruebas demo está vacía — conecta un feed para ver estadísticas reales del catálogo.",
"dashboard.workflowHint": "Importar → enriquecer → publicar. Salta al siguiente paso para {name}.",
"dashboard.overviewHint": "Totales en vivo para {name}",
"dashboard.demoEmptyMessage":
"Cambia a A1 (u otra empresa con datos) en el encabezado, o conecta un feed aquí para poblar esta zona de pruebas.",
"Conecta un feed aquí para poblar esta zona de pruebas, o cambia a otra empresa en el encabezado.",
"dashboard.emptyTitle": "Aún no hay datos de catálogo",
"dashboard.emptyMessage": "Conecta un feed o sube un CSV para empezar a crear tu catálogo.",
"dashboard.connectFeedAnyway": "Conectar feed de todos modos",
+16 -16
View File
@@ -142,10 +142,10 @@ export const EXTRA = {
"settings.inviteRole": "Rol",
"settings.inviteRoleHint": "Los miembros gestionan productos y feeds. Los administradores también pueden invitar compañeros y cambiar la configuración de la empresa.",
"settings.sendInvite": "Enviar invitación",
"dashboard.demoEmptyHint": "La zona de pruebas demo está vacía — cambia a A1 o conecta un feed para ver estadísticas reales del catálogo.",
"dashboard.demoEmptyHint": "La zona de pruebas demo está vacía — conecta un feed para ver estadísticas reales del catálogo.",
"dashboard.workflowHint": "Importar → enriquecer → publicar. Salta al siguiente paso para {name}.",
"dashboard.overviewHint": "Totales en vivo para {name}",
"dashboard.demoEmptyMessage": "Cambia a A1 (u otra empresa con datos) en el encabezado, o conecta un feed aquí para poblar esta zona de pruebas.",
"dashboard.demoEmptyMessage": "Conecta un feed aquí para poblar esta zona de pruebas, o cambia a otra empresa en el encabezado.",
"dashboard.emptyTitle": "Aún no hay datos de catálogo",
"dashboard.emptyMessage": "Conecta un feed o sube un CSV para empezar a crear tu catálogo.",
"dashboard.connectFeedAnyway": "Conectar feed de todos modos",
@@ -375,10 +375,10 @@ export const EXTRA = {
"settings.inviteRole": "Rôle",
"settings.inviteRoleHint": "Les membres gèrent les produits et les flux. Les administrateurs peuvent aussi inviter des coéquipiers et modifier les paramètres de l'entreprise.",
"settings.sendInvite": "Envoyer l'invitation",
"dashboard.demoEmptyHint": "Le bac à sable démo est vide — basculez vers A1 ou connectez un flux pour voir de vraies stats catalogue.",
"dashboard.demoEmptyHint": "Le bac à sable démo est vide — connectez un flux pour voir de vraies stats catalogue.",
"dashboard.workflowHint": "Importer → enrichir → publier. Passez à l'étape suivante pour {name}.",
"dashboard.overviewHint": "Totaux en direct pour {name}",
"dashboard.demoEmptyMessage": "Basculez vers A1 (ou une autre entreprise seedée) dans l'en-tête, ou connectez un flux ici pour remplir ce bac à sable.",
"dashboard.demoEmptyMessage": "Connectez un flux ici pour remplir ce bac à sable, ou basculez vers une autre entreprise dans l'en-tête.",
"dashboard.emptyTitle": "Pas encore de données catalogue",
"dashboard.emptyMessage": "Connectez un flux ou téléversez un CSV pour commencer à construire votre catalogue.",
"dashboard.connectFeedAnyway": "Connecter un flux quand même",
@@ -606,10 +606,10 @@ export const EXTRA = {
"settings.inviteRole": "Rolle",
"settings.inviteRoleHint": "Mitglieder verwalten Produkte und Feeds. Admins können auch Teammitglieder einladen und Unternehmenseinstellungen ändern.",
"settings.sendInvite": "Einladung senden",
"dashboard.demoEmptyHint": "Demo-Sandbox ist leer — wechseln Sie zu A1 oder verbinden Sie einen Feed, um echte Katalogstatistiken zu sehen.",
"dashboard.demoEmptyHint": "Demo-Sandbox ist leer — verbinden Sie einen Feed, um echte Katalogstatistiken zu sehen.",
"dashboard.workflowHint": "Importieren → anreichern → veröffentlichen. Zum nächsten Schritt für {name}.",
"dashboard.overviewHint": "Live-Summen für {name}",
"dashboard.demoEmptyMessage": "Wechseln Sie in der Kopfzeile zu A1 (oder einem anderen Seed-Unternehmen) oder verbinden Sie hier einen Feed, um diese Sandbox zu füllen.",
"dashboard.demoEmptyMessage": "Verbinden Sie hier einen Feed, um diese Sandbox zu füllen, oder wechseln Sie in der Kopfzeile zu einem anderen Unternehmen.",
"dashboard.emptyTitle": "Noch keine Katalogdaten",
"dashboard.emptyMessage": "Verbinden Sie einen Feed oder laden Sie eine CSV hoch, um Ihren Katalog aufzubauen.",
"dashboard.connectFeedAnyway": "Feed trotzdem verbinden",
@@ -837,10 +837,10 @@ export const EXTRA = {
"settings.inviteRole": "Ruolo",
"settings.inviteRoleHint": "I membri gestiscono prodotti e feed. Gli amministratori possono anche invitare colleghi e modificare le impostazioni dell'azienda.",
"settings.sendInvite": "Invia invito",
"dashboard.demoEmptyHint": "La sandbox demo è vuota — passa ad A1 o collega un feed per vedere statistiche reali del catalogo.",
"dashboard.demoEmptyHint": "La sandbox demo è vuota — collega un feed per vedere statistiche reali del catalogo.",
"dashboard.workflowHint": "Importa → arricchisci → pubblica. Vai al passo successivo per {name}.",
"dashboard.overviewHint": "Totali in tempo reale per {name}",
"dashboard.demoEmptyMessage": "Passa ad A1 (o un'altra azienda con dati) nell'intestazione, oppure collega un feed qui per popolare questa sandbox.",
"dashboard.demoEmptyMessage": "Collega un feed qui per popolare questa sandbox, oppure passa a un'altra azienda nell'intestazione.",
"dashboard.emptyTitle": "Ancora nessun dato di catalogo",
"dashboard.emptyMessage": "Collega un feed o carica un CSV per iniziare a costruire il catalogo.",
"dashboard.connectFeedAnyway": "Collega comunque un feed",
@@ -1068,10 +1068,10 @@ export const EXTRA = {
"settings.inviteRole": "Papel",
"settings.inviteRoleHint": "Os membros gerem produtos e feeds. Os administradores também podem convidar colegas e alterar as definições da empresa.",
"settings.sendInvite": "Enviar convite",
"dashboard.demoEmptyHint": "A sandbox de demonstração está vazia — mude para A1 ou ligue um feed para ver estatísticas reais do catálogo.",
"dashboard.demoEmptyHint": "A sandbox de demonstração está vazia — ligue um feed para ver estatísticas reais do catálogo.",
"dashboard.workflowHint": "Importar → enriquecer → publicar. Salte para o passo seguinte para {name}.",
"dashboard.overviewHint": "Totais em direto para {name}",
"dashboard.demoEmptyMessage": "Mude para A1 (ou outra empresa com dados) no cabeçalho, ou ligue um feed aqui para preencher esta sandbox.",
"dashboard.demoEmptyMessage": "Ligue um feed aqui para preencher esta sandbox, ou mude para outra empresa no cabeçalho.",
"dashboard.emptyTitle": "Ainda sem dados de catálogo",
"dashboard.emptyMessage": "Ligue um feed ou carregue um CSV para começar a criar o seu catálogo.",
"dashboard.connectFeedAnyway": "Ligar feed mesmo assim",
@@ -1299,10 +1299,10 @@ export const EXTRA = {
"settings.inviteRole": "Rol",
"settings.inviteRoleHint": "Leden beheren producten en feeds. Beheerders kunnen ook teamleden uitnodigen en bedrijfsinstellingen wijzigen.",
"settings.sendInvite": "Uitnodiging verzenden",
"dashboard.demoEmptyHint": "Demo-sandbox is leeg — schakel over naar A1 of koppel een feed om echte catalogusstatistieken te zien.",
"dashboard.demoEmptyHint": "Demo-sandbox is leeg — koppel een feed om echte catalogusstatistieken te zien.",
"dashboard.workflowHint": "Importeren → verrijken → publiceren. Ga naar de volgende stap voor {name}.",
"dashboard.overviewHint": "Live totalen voor {name}",
"dashboard.demoEmptyMessage": "Schakel in de header over naar A1 (of een ander geseeded bedrijf), of koppel hier een feed om deze sandbox te vullen.",
"dashboard.demoEmptyMessage": "Koppel hier een feed om deze sandbox te vullen, of schakel in de header over naar een ander bedrijf.",
"dashboard.emptyTitle": "Nog geen catalogusgegevens",
"dashboard.emptyMessage": "Koppel een feed of upload een CSV om uw catalogus op te bouwen.",
"dashboard.connectFeedAnyway": "Feed toch koppelen",
@@ -1530,10 +1530,10 @@ export const EXTRA = {
"settings.inviteRole": "Rola",
"settings.inviteRoleHint": "Członkowie zarządzają produktami i feedami. Administratorzy mogą też zapraszać współpracowników i zmieniać ustawienia firmy.",
"settings.sendInvite": "Wyślij zaproszenie",
"dashboard.demoEmptyHint": "Piaskownica demo jest pusta — przełącz na A1 lub podłącz feed, aby zobaczyć realne statystyki katalogu.",
"dashboard.demoEmptyHint": "Piaskownica demo jest pusta — podłącz feed, aby zobaczyć realne statystyki katalogu.",
"dashboard.workflowHint": "Importuj → wzbogacaj → publikuj. Przejdź do następnego kroku dla {name}.",
"dashboard.overviewHint": "Bieżące sumy dla {name}",
"dashboard.demoEmptyMessage": "Przełącz na A1 (lub inną firmę z danymi) w nagłówku albo podłącz tu feed, aby wypełnić tę piaskownicę.",
"dashboard.demoEmptyMessage": "Podłącz tu feed, aby wypełnić tę piaskownicę, albo przełącz na inną firmę w nagłówku.",
"dashboard.emptyTitle": "Brak jeszcze danych katalogu",
"dashboard.emptyMessage": "Podłącz feed lub prześlij CSV, aby zacząć budować katalog.",
"dashboard.connectFeedAnyway": "Podłącz feed mimo to",
@@ -1761,10 +1761,10 @@ export const EXTRA = {
"settings.inviteRole": "ロール",
"settings.inviteRoleHint": "メンバーは商品とフィードを管理します。管理者は同僚の招待と会社設定の変更もできます。",
"settings.sendInvite": "招待を送信",
"dashboard.demoEmptyHint": "デモサンドボックスは空です — A1に切り替えるかフィードを接続して実際のカタログ統計を表示します。",
"dashboard.demoEmptyHint": "デモサンドボックスは空です — フィードを接続して実際のカタログ統計を表示します。",
"dashboard.workflowHint": "インポート → 強化 → 公開。{name} の次のステップへ。",
"dashboard.overviewHint": "{name} のリアルタイム合計",
"dashboard.demoEmptyMessage": "ヘッダーでA1(または別のシード済み会社)に切り替えるか、ここでフィードを接続してサンドボックスにデータを入れます。",
"dashboard.demoEmptyMessage": "ここでフィードを接続してサンドボックスにデータを入れるか、ヘッダーで別の会社に切り替えます。",
"dashboard.emptyTitle": "まだカタログデータがありません",
"dashboard.emptyMessage": "フィードを接続するかCSVをアップロードして、カタログの構築を開始します。",
"dashboard.connectFeedAnyway": "それでもフィードを接続",
+20 -20
View File
@@ -219,15 +219,15 @@
"pl": "Najpierw ustaw hasło:",
"ja": "先にパスワードを設定:"
},
"use the invite link from your email. If the link went to an old address (email drift), ask a company admin to re-issue a set-password invite to {email}.": {
"es": "usa el enlace de invitación de tu correo. Si el enlace fue a una dirección antigua (cambio de correo), pide a un administrador de la empresa que emita una nueva invitación para establecer contraseña a {email}.",
"fr": "utilisez le lien d'invitation de votre e-mail. Si le lien a été envoyé à une ancienne adresse (dérive d'e-mail), demandez à un administrateur de l'entreprise de renvoyer une invitation de définition de mot de passe à {email}.",
"de": "nutzen Sie den Einladungslink aus Ihrer E-Mail. Wenn der Link an eine alte Adresse ging (E-Mail-Drift), bitten Sie einen Unternehmens-Admin, eine neue Passwort-Einladung an {email} auszustellen.",
"it": "usa il link di invito dalla tua email. Se il link è andato a un indirizzo vecchio (deriva email), chiedi a un amministratore dell'azienda di riemettere un invito per impostare la password a {email}.",
"pt": "utilize o link do convite do seu e-mail. Se o link foi para um endereço antigo (desvio de e-mail), peça a um administrador da empresa para emitir um novo convite de definição de palavra-passe para {email}.",
"nl": "gebruik de uitnodigingslink uit uw e-mail. Als de link naar een oud adres ging (e-maildrift), vraag dan een bedrijfsbeheerder om een nieuwe set-wachtwoorduitnodiging naar {email} te sturen.",
"pl": "użyj linku zaproszenia z e-maila. Jeśli link poszedł na stary adres (dryf e-mail), poproś administratora firmy o ponowne wystawienie zaproszenia do ustawienia hasła na {email}.",
"ja": "メールの招待リンクを使用してください。リンクが古いアドレスに送られた場合(メール変更)、会社の管理者に {email} 向けのパスワード設定招待の再発行を依頼してください。"
"use the invite link from your email. If you need a new link, ask a company admin to re-issue a set-password link to {email}.": {
"es": "usa el enlace de invitación de tu correo. Si necesitas un enlace nuevo, pide a un administrador de la empresa que reemita un enlace para establecer contraseña a {email}.",
"fr": "utilisez le lien d'invitation de votre e-mail. Si vous avez besoin d'un nouveau lien, demandez à un administrateur de l'entreprise de réémettre un lien de définition de mot de passe à {email}.",
"de": "nutzen Sie den Einladungslink aus Ihrer E-Mail. Wenn Sie einen neuen Link brauchen, bitten Sie einen Unternehmens-Admin, einen Link zum Festlegen des Passworts an {email} auszustellen.",
"it": "usa il link di invito dalla tua email. Se ti serve un nuovo link, chiedi a un amministratore dell'azienda di riemettere un link per impostare la password a {email}.",
"pt": "utilize o link do convite do seu e-mail. Se precisar de um novo link, peça a um administrador da empresa para reemitir um link de definição de palavra-passe para {email}.",
"nl": "gebruik de uitnodigingslink uit uw e-mail. Als u een nieuwe link nodig hebt, vraag dan een bedrijfsbeheerder om een set-wachtwoordlink opnieuw uit te geven naar {email}.",
"pl": "użyj linku zaproszenia z e-maila. Jeśli potrzebujesz nowego linku, poproś administratora firmy o ponowne wystawienie linku do ustawienia hasła na {email}.",
"ja": "メールの招待リンクを使用してください。新しいリンクが必要な場合は、会社の管理者に {email} 向けのパスワード設定リンクの再発行を依頼してください。"
},
"your email": {
"es": "tu correo",
@@ -549,15 +549,15 @@
"pl": "To zaproszenie jest nieprawidłowe lub wygasło. Poproś administratora firmy o nowe zaproszenie, a następnie otwórz nowy link (lub wklej nowy token poniżej).",
"ja": "この招待は無効または期限切れです。会社の管理者に新しい招待の送信を依頼し、新しいリンクを開くか(下に新しいトークンを貼り付けてください)。"
},
"This set-password link is invalid or expired. Ask a company or platform admin to re-issue it, then open the new link (or paste the new token below).": {
"es": "Este enlace para establecer contraseña no es válido o ha caducado. Pide a un administrador de la empresa o de la plataforma que lo reemita y abre el nuevo enlace (o pega el nuevo token abajo).",
"fr": "Ce lien de définition de mot de passe est invalide ou expiré. Demandez à un administrateur de l'entreprise ou de la plateforme de le réémettre, puis ouvrez le nouveau lien (ou collez le nouveau jeton ci-dessous).",
"de": "Dieser Passwort-Link ist ungültig oder abgelaufen. Bitten Sie einen Unternehmens- oder Plattform-Admin um Neuausstellung und öffnen Sie den neuen Link (oder fügen Sie das neue Token unten ein).",
"it": "Questo link per impostare la password non è valido o è scaduto. Chiedi a un amministratore dell'azienda o della piattaforma di riemetterlo, poi apri il nuovo link (o incolla il nuovo token qui sotto).",
"pt": "Este link de definição de palavra-passe é inválido ou expirou. Peça a um administrador da empresa ou da plataforma para o reemitir e abra o novo link (ou cole o novo token abaixo).",
"nl": "Deze set-wachtwoordlink is ongeldig of verlopen. Vraag een bedrijfs- of platformbeheerder om hem opnieuw uit te geven en open de nieuwe link (of plak het nieuwe token hieronder).",
"pl": "Ten link do ustawienia hasła jest nieprawidłowy lub wygasł. Poproś administratora firmy lub platformy o ponowne wystawienie, a następnie otwórz nowy link (lub wklej nowy token poniżej).",
"ja": "このパスワード設定リンクは無効または期限切れです。会社またはプラットフォームの管理者に再発行を依頼し、新しいリンクを開くか(下に新しいトークンを貼り付けてください)。"
"This set-password link is invalid or expired. Ask a company admin to re-issue a set-password link to this email, then open the new link (or paste the new token below).": {
"es": "Este enlace para establecer contraseña no es válido o ha caducado. Pide a un administrador de la empresa que reemita un enlace para establecer contraseña a este correo y abre el nuevo enlace (o pega el nuevo token abajo).",
"fr": "Ce lien de définition de mot de passe est invalide ou expiré. Demandez à un administrateur de l'entreprise de réémettre un lien de définition de mot de passe vers cet e-mail, puis ouvrez le nouveau lien (ou collez le nouveau jeton ci-dessous).",
"de": "Dieser Passwort-Link ist ungültig oder abgelaufen. Bitten Sie einen Unternehmens-Admin, einen Link zum Festlegen des Passworts an diese E-Mail auszustellen, und öffnen Sie den neuen Link (oder fügen Sie das neue Token unten ein).",
"it": "Questo link per impostare la password non è valido o è scaduto. Chiedi a un amministratore dell'azienda di riemettere un link per impostare la password a questa email, poi apri il nuovo link (o incolla il nuovo token qui sotto).",
"pt": "Este link de definição de palavra-passe é inválido ou expirou. Peça a um administrador da empresa para reemitir um link de definição de palavra-passe para este e-mail e abra o novo link (ou cole o novo token abaixo).",
"nl": "Deze set-wachtwoordlink is ongeldig of verlopen. Vraag een bedrijfsbeheerder om een set-wachtwoordlink opnieuw uit te geven naar dit e-mailadres en open de nieuwe link (of plak het nieuwe token hieronder).",
"pl": "Ten link do ustawienia hasła jest nieprawidłowy lub wygasł. Poproś administratora firmy o ponowne wystawienie linku do ustawienia hasła na ten e-mail, a następnie otwórz nowy link (lub wklej nowy token poniżej).",
"ja": "このパスワード設定リンクは無効または期限切れです。会社の管理者にこのメール向けのパスワード設定リンクの再発行を依頼し、新しいリンクを開くか(下に新しいトークンを貼り付けてください)。"
},
"You're signed in as a different email than this invite.": {
"es": "Has iniciado sesión con un correo distinto al de esta invitación.",
@@ -569,7 +569,7 @@
"pl": "Jesteś zalogowany na inny e-mail niż w tym zaproszeniu.",
"ja": "この招待とは別のメールアドレスでログインしています。"
},
"Expired link? Ask an admin to re-issue — there is no self-serve resend API. Platform admins:": {
"Expired link? Ask a company admin to re-issue — there is no self-serve resend API.": {
"es": "¿Enlace caducado? Pide a un administrador que lo reemita — no hay API de reenvío autoservicio. Administradores de plataforma:",
"fr": "Lien expiré ? Demandez à un administrateur de le réémettre — il n'y a pas d'API de renvoi en libre-service. Administrateurs de plateforme :",
"de": "Abgelaufener Link? Bitten Sie einen Admin um Neuausstellung — es gibt keine Self-Service-API zum erneuten Senden. Plattform-Admins:",
@@ -729,7 +729,7 @@
"pl": "Ścieżka ponownego wystawienia:",
"ja": "再発行の手順:"
},
"if your real login email changed (email drift), ask a company admin to revoke this invite and send a new one to the email you use to sign in. Platform admins can also re-issue set-password links from Admin → Users.": {
"ask a company admin to re-issue a set-password link to this email, or to revoke this invite and send a new one to the email you use to sign in.": {
"es": "si cambió tu correo real de acceso (desfase de correo), pide a un administrador de la empresa que revoque esta invitación y envíe una nueva al correo con el que inicias sesión. Los administradores de plataforma también pueden reemitir enlaces para establecer contraseña desde Admin → Usuarios.",
"fr": "si votre vrai e-mail de connexion a changé (dérive d'e-mail), demandez à un administrateur de l'entreprise de révoquer cette invitation et d'en envoyer une nouvelle à l'e-mail que vous utilisez pour vous connecter. Les administrateurs de la plateforme peuvent aussi réémettre des liens de définition de mot de passe depuis Admin → Utilisateurs.",
"de": "wenn sich Ihre echte Anmelde-E-Mail geändert hat (E-Mail-Drift), bitten Sie einen Unternehmens-Admin, diese Einladung zu widerrufen und eine neue an die E-Mail zu senden, mit der Sie sich anmelden. Plattform-Admins können Passwort-Links auch unter Admin → Benutzer erneut ausstellen.",
+11 -6
View File
@@ -57,19 +57,24 @@ export const handle: Handle = async ({ event, resolve }) => {
}
const pathname = event.url.pathname;
// Never keep credentials in the query string (e.g. native GET before hydration).
if (
(pathname === "/login" ||
const authCredentialPath =
pathname === "/login" ||
pathname === "/register" ||
pathname === "/accept-invite" ||
pathname === "/forgot-password" ||
pathname === "/reset-password") &&
event.url.searchParams.has("password")
) {
pathname === "/reset-password";
// Never keep credentials in the query string (e.g. native GET before hydration).
if (authCredentialPath && event.url.searchParams.has("password")) {
const clean = new URL(event.url);
clean.searchParams.delete("password");
clean.searchParams.delete("token");
redirect(303, `${clean.pathname}${clean.search}`);
}
// Redact invite/reset tokens from SSR page.url. Do not 303-strip: old emails
// still send ?token= and the client reads window.location once, then replaceState.
if (authCredentialPath && event.url.searchParams.has("token")) {
event.url.searchParams.delete("token");
}
const isRapiDocVendor = pathname.startsWith("/vendor/rapidoc/");
// Serve precompressed RapiDoc when client accepts gzip (copy-rapidoc-ui.mjs).
+3 -2
View File
@@ -15,6 +15,7 @@
import { browser } from "$app/environment";
import { env } from "$env/dynamic/public";
import { redactSensitiveUrl } from "./auth-link-token";
import {
CONSENT_STORAGE_KEY,
CONSENT_WAIT_FOR_UPDATE_MS,
@@ -165,9 +166,9 @@ export function trackPageview(path: string, opts?: { title?: string; location?:
if (!isAnalyticsGranted()) return;
pushDataLayer({
event: "page_view",
page_path: path,
page_path: redactSensitiveUrl(path),
page_title: opts?.title ?? document.title,
page_location: opts?.location ?? window.location.href
page_location: redactSensitiveUrl(opts?.location ?? window.location.href)
});
}
+13 -4
View File
@@ -90,13 +90,21 @@ describe("System assistant matching", () => {
["ai integrations", "open_ai_integrations"],
["email sending", "open_email_integrations"],
["usage and billing", "open_billing"],
["company settings", "open_settings"],
["platform admin", "open_admin"]
["company settings", "open_settings"]
] as const;
for (const [utterance, id] of cases) {
assert.equal(matchIntent(utterance)?.intent.id, id, utterance);
}
});
it("does not send customers to platform admin", () => {
assert.notEqual(matchIntent("platform admin")?.intent.id, "open_admin");
assert.notEqual(matchIntent("go to admin")?.intent.id, "open_admin");
});
it("matches open_admin only when staff intents are allowed", () => {
assert.equal(matchIntent("platform admin", { allowStaffIntents: true })?.intent.id, "open_admin");
});
});
describe("bot / AI identity answers", () => {
@@ -114,10 +122,11 @@ describe("bot / AI identity answers", () => {
assert.equal(matchIntent("are you an LLM chatbot")?.intent.id, "identity_system");
});
it("identity reply says System assistant and denies LLM chatbot", () => {
it("identity reply says System assistant without LLM chatbot wording", () => {
const msg = buildIdentityReply();
assert.match(msg.text, /System assistant/i);
assert.match(msg.text, /not an LLM chatbot/i);
assert.match(msg.text, /navigate Descrybe/i);
assert.doesNotMatch(msg.text, /LLM chatbot/i);
assert.doesNotMatch(msg.text, /\bno LLM\b/i);
assert.doesNotMatch(msg.text, /\bno AI\b/i);
});
+1 -1
View File
@@ -75,7 +75,7 @@ export function buildIdentityReply(): AssistantMessage {
return makeMessage({
role: "assistant",
kind: "text",
text: "I am not an LLM chatbot. I am the System assistant — I help you navigate Descrybe, run supported actions after you confirm, and guide setup using built-in workflows."
text: "I am the System assistant — I help you navigate Descrybe, run supported actions after you confirm, and guide setup using built-in workflows."
});
}
+1
View File
@@ -13,6 +13,7 @@ export type {
export { INTENT_REGISTRY, intentById, QUICK_START_REPLIES } from "./intents.ts";
export { matchIntent, matchIdentityQuestion, normalizeUtterance, extractUrl } from "./match.ts";
export type { MatchIntentOptions } from "./match.ts";
export {
makeMessage,
idleFlow,
+1
View File
@@ -481,6 +481,7 @@ export const INTENT_REGISTRY: IntentDefinition[] = [
description: "Navigate to Platform admin (staff only).",
requiresConfirm: false,
canExecute: false,
staffOnly: true,
route: "/admin",
selector: '[data-assistant-target="nav-admin"],[data-tour="nav-admin"]',
guideSteps: [{ title: "You're on Platform admin", detail: "Users, billing, support, and gates" }]
+7 -1
View File
@@ -1,6 +1,11 @@
import { INTENT_REGISTRY } from "./intents.ts";
import type { IntentMatch } from "./types.ts";
export type MatchIntentOptions = {
/** Include staffOnly intents such as open_admin. Default false so customers are not sent to /admin. */
allowStaffIntents?: boolean;
};
const URL_RE = /https?:\/\/[^\s<>"']+/i;
/** Normalize for phrase/keyword matching. */
@@ -40,7 +45,7 @@ export function matchIdentityQuestion(raw: string): boolean {
* Score intents by phrase containment and keyword hits.
* Returns null when nothing clears the confidence floor.
*/
export function matchIntent(raw: string): IntentMatch | null {
export function matchIntent(raw: string, opts?: MatchIntentOptions): IntentMatch | null {
const text = normalizeUtterance(raw);
if (!text) return null;
@@ -54,6 +59,7 @@ export function matchIntent(raw: string): IntentMatch | null {
for (const intent of INTENT_REGISTRY) {
if (intent.id === "identity_system") continue;
if (intent.staffOnly && !opts?.allowStaffIntents) continue;
let score = 0;
for (const phrase of intent.phrases) {
+4 -1
View File
@@ -1,4 +1,5 @@
import { trackEvent } from "$lib/analytics";
import { authSession } from "$lib/auth-session.svelte";
import { intentById, QUICK_START_REPLIES } from "./intents.ts";
import {
buildApiExamplesMessage,
@@ -557,7 +558,9 @@ function createAssistantController() {
return;
}
const matched = matchIntent(text);
const matched = matchIntent(text, {
allowStaffIntents: authSession.isSupportDesk
});
if (!matched) {
push(buildUnknownReply());
return;
+2
View File
@@ -100,6 +100,8 @@ export type IntentDefinition = {
/** Prefer data-assistant-target; falls back to data-tour. */
selector?: string;
guideSteps: AssistantStep[];
/** When true, omit from matchIntent unless allowStaffIntents. */
staffOnly?: boolean;
};
export type IntentMatch = {
+69
View File
@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { consumeAuthLinkSecrets, redactSensitiveUrl, tokenFromHash } from "./auth-link-token.ts";
describe("tokenFromHash", () => {
it("reads token from hash params", () => {
assert.equal(tokenFromHash("#token=abc123"), "abc123");
assert.equal(tokenFromHash("token=abc123&mode=set-password"), "abc123");
assert.equal(tokenFromHash("#mode=set-password"), "");
});
});
describe("consumeAuthLinkSecrets", () => {
it("prefers hash token over query and strips both", () => {
const got = consumeAuthLinkSecrets(
"https://app.example.com/accept-invite?token=old&mode=set-password#token=fresh&mode=set-password"
);
assert.equal(got.token, "fresh");
assert.equal(got.mode, "set-password");
assert.equal(got.cleanedPathSearch, "/accept-invite?mode=set-password");
assert.equal(got.stripped, true);
});
it("accepts legacy query token once then strips it", () => {
const got = consumeAuthLinkSecrets(
"https://app.example.com/accept-invite?token=legacyTok&mode=set-password"
);
assert.equal(got.token, "legacyTok");
assert.equal(got.mode, "set-password");
assert.equal(got.cleanedPathSearch, "/accept-invite?mode=set-password");
assert.equal(got.stripped, true);
});
it("reads invite hash without query", () => {
const got = consumeAuthLinkSecrets("https://app.example.com/accept-invite#token=invTok");
assert.equal(got.token, "invTok");
assert.equal(got.mode, "");
assert.equal(got.cleanedPathSearch, "/accept-invite");
assert.equal(got.stripped, true);
});
it("leaves the URL alone when no token is present", () => {
const got = consumeAuthLinkSecrets("https://app.example.com/accept-invite");
assert.equal(got.token, "");
assert.equal(got.stripped, false);
assert.equal(got.cleanedPathSearch, "/accept-invite");
});
});
describe("redactSensitiveUrl", () => {
it("redacts query token and password", () => {
assert.equal(
redactSensitiveUrl("/accept-invite?token=secret&mode=set-password"),
"/accept-invite?token=REDACTED&mode=set-password"
);
});
it("redacts hash token and docs API keys in page_location", () => {
const got = redactSensitiveUrl(
"https://app.example.com/accept-invite#token=secret&mode=set-password"
);
assert.equal(got.includes("secret"), false);
assert.match(got, /token=REDACTED/);
assert.equal(
redactSensitiveUrl("https://app.example.com/docs?k=dk_abcdefghijklmnop"),
"https://app.example.com/docs?k=dk_REDACTED"
);
});
});
+73
View File
@@ -0,0 +1,73 @@
/** Parse invite / reset secrets from a URL hash (`#token=` / `#token=&mode=`). */
export function paramsFromHash(hash: string): URLSearchParams {
const raw = hash.startsWith("#") ? hash.slice(1) : hash;
return new URLSearchParams(raw);
}
export function tokenFromHash(hash: string): string {
return (paramsFromHash(hash).get("token") ?? "").trim();
}
export type AuthLinkMode = "invite" | "set-password";
/**
* Read token from hash (preferred) or query (legacy emails), then strip both
* from the address bar. Mode may live in hash or query; only `set-password` is special.
*/
export function consumeAuthLinkSecrets(href: string): {
token: string;
mode: AuthLinkMode | "";
cleanedPathSearch: string;
stripped: boolean;
} {
const url = new URL(href);
const hashParams = paramsFromHash(url.hash);
const fromHash = (hashParams.get("token") ?? "").trim();
const fromQuery = (url.searchParams.get("token") ?? "").trim();
const token = fromHash || fromQuery;
const rawMode = (hashParams.get("mode") ?? url.searchParams.get("mode") ?? "")
.trim()
.toLowerCase();
const mode: AuthLinkMode | "" = rawMode === "set-password" ? "set-password" : "";
const hadQueryToken = url.searchParams.has("token");
url.searchParams.delete("token");
url.hash = "";
return {
token,
mode,
cleanedPathSearch: `${url.pathname}${url.search}`,
stripped: Boolean(fromHash) || hadQueryToken
};
}
const SENSITIVE_QUERY_KEYS = new Set(["token", "password"]);
/** Redact query/hash secrets and docs API keys from URLs sent to GTM. */
export function redactSensitiveUrl(input: string): string {
if (!input) return input;
const redactDk = (value: string) => value.replace(/\bdk_[A-Za-z0-9_-]{8,}\b/g, "dk_REDACTED");
try {
const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(input);
const u = hasScheme ? new URL(input) : new URL(input, "https://redact.invalid");
for (const key of [...u.searchParams.keys()]) {
if (SENSITIVE_QUERY_KEYS.has(key.toLowerCase())) {
u.searchParams.set(key, "REDACTED");
}
}
if (u.hash) {
const hp = new URLSearchParams(u.hash.startsWith("#") ? u.hash.slice(1) : u.hash);
let hashChanged = false;
for (const key of [...hp.keys()]) {
if (SENSITIVE_QUERY_KEYS.has(key.toLowerCase())) {
hp.set(key, "REDACTED");
hashChanged = true;
}
}
if (hashChanged) u.hash = hp.toString();
}
const out = hasScheme ? u.toString() : `${u.pathname}${u.search}${u.hash}`;
return redactDk(out);
} catch {
return redactDk(input.replace(/([?&#](?:token|password)=)[^&]*/gi, "$1REDACTED"));
}
}
@@ -28,17 +28,17 @@ export const SECTION_MARKERS: Record<
/** Canonical shared intro (same idea as CategoryEnhanceUserTemplate preamble). */
export const DEFAULT_ENHANCE_PREAMBLE =
'Your reply is parsed as JSON {"name":"string","description":"string","meta_title":"string","meta_description":"string","attrs":{}} only (system schema). Write all string fields in {{language}} (do not hardcode a language).';
"Write all product text in {{language}} (do not hardcode a language). Keep the title, description, SEO meta, and attributes consistent with the product evidence below.";
/** Minimal role bodies used when a section is empty on compose (keeps markers valid). */
export const DEFAULT_SECTION_BODIES: Record<EnhancePromptSectionId, string> = {
title:
'Role: title. Build JSON "name": short retail title from Title formula + Attrs — never brand-only. Include product type and full model when evidence exists; follow any Title formula constraints that follow; use Attrs.\nName: {{name}}',
"Write a short retail product title from the title formula and attributes — never brand-only. Include product type and full model when evidence exists; follow any title formula constraints that follow.\nName: {{name}}",
description:
'Role: description. Build JSON "description": product body HTML only (not SEO meta). When a Description formula follows, emit ONE HTML string covering each section in order; otherwise prefer 1-3 factual paragraphs as ONE string with limited HTML (<h2><p><ul><li>).\nDescription: {{description}}',
meta: 'Role: meta. Build JSON "meta_title" and "meta_description" as plain SEO text (never HTML). meta_title: 50-60 chars; meta_description: 120-155 chars; follow any SEO meta formula that follows; never copy the full description HTML into meta_description.',
"Write the product description as HTML (not SEO meta). When a description formula follows, cover each section in order as one HTML string; otherwise prefer 13 factual paragraphs with simple HTML (<h2><p><ul><li>).\nDescription: {{description}}",
meta: "Write the SEO title (5060 characters) and SEO description (120155 characters) as plain text, never HTML. Follow any SEO meta formula that follows; do not copy the full description into the SEO description.",
attributes:
'Role: attributes. Build JSON "attrs" as an object of attribute_key → value strings. Prefer Allowed attribute keys / Title formula attr slots that follow; remap near-miss labels onto those keys; fill missing keys only from Name/Description/Category/Attrs evidence; never invent specs; omit unknown keys; never invent dimensions.\nCategory: {{category}}\nAttrs: {{attrs}}'
"Fill product attributes as key/value pairs. Prefer allowed attribute keys and title-formula slots; remap near-miss labels onto those keys; fill missing keys only from name, description, category, and attributes evidence; never invent specs or dimensions; omit unknown keys.\nCategory: {{category}}\nAttrs: {{attrs}}"
};
export type SectionSchemaHint = {
@@ -31,7 +31,6 @@
const companyHint = $derived.by(() => {
const n = companyName;
if (/^platform demo$/i.test(n) || /^demo$/i.test(n)) return "Platform Demo";
if (n === "Local Demo Co" || /^a1(\s|$)/i.test(n)) return "A1 Slovenija";
return n;
});
const impersonating = $derived(Boolean(me.impersonating));
@@ -129,15 +129,9 @@
</div>
<p class="mx-auto mt-6 max-w-3xl text-center text-sm text-text-muted">
{i18n.t("pricing.section.publicBefore")}
<a href="/plans" class="font-medium text-link hover:underline"
>{i18n.t("pricing.section.plansLink")}</a
>
{i18n.t("pricing.section.publicOr")}
<a href="/billing" class="font-medium text-link hover:underline"
>{i18n.t("pricing.section.billingLink")}</a
>
{i18n.t("pricing.section.publicAfter")}
{i18n.t("pricing.section.guestPublicBefore")}
<a href="/login" class="font-medium text-link hover:underline">{i18n.t("site.logIn")}</a>
{i18n.t("pricing.section.guestPublicAfter")}
</p>
<Card class="mt-12 border-border bg-surface">
+39
View File
@@ -132,5 +132,44 @@ export function validateDocsGuideTree(tree: DocsGuideTree): string[] {
errors.push(`escapeLinks docs href must use RapiDoc ids (no braces): ${link.href}`);
}
}
collectForbiddenDocsCopy(tree, errors);
return errors;
}
function rejectForbiddenDocsCopy(where: string, text: string | undefined, errors: string[]): void {
if (!text) return;
if (/legacy/i.test(text)) {
errors.push(`${where} must not mention legacy`);
}
if (/\bA1\b/.test(text)) {
errors.push(`${where} must not mention A1`);
}
}
function collectForbiddenDocsCopy(tree: DocsGuideTree, errors: string[]): void {
rejectForbiddenDocsCopy("disclaimer", tree.disclaimer, errors);
for (const link of tree.escapeLinks) {
rejectForbiddenDocsCopy(`escapeLinks "${link.href}"`, link.label, errors);
}
for (const [id, node] of Object.entries(tree.nodes)) {
rejectForbiddenDocsCopy(`node "${id}" title`, node.title, errors);
rejectForbiddenDocsCopy(`node "${id}" body`, node.body, errors);
if (node.kind === "question") {
for (const c of node.choices) {
rejectForbiddenDocsCopy(`question "${id}" choice "${c.id}" label`, c.label, errors);
rejectForbiddenDocsCopy(`question "${id}" choice "${c.id}" description`, c.description, errors);
}
continue;
}
rejectForbiddenDocsCopy(`answer "${id}" outcome`, node.outcome, errors);
rejectForbiddenDocsCopy(`answer "${id}" tip`, node.tip, errors);
rejectForbiddenDocsCopy(`answer "${id}" warning`, node.warning, errors);
rejectForbiddenDocsCopy(`answer "${id}" authNote`, node.authNote, errors);
for (const ep of node.endpoints ?? []) {
rejectForbiddenDocsCopy(`answer "${id}" endpoint ${ep.method} ${ep.path}`, ep.summary, errors);
}
for (const link of node.links ?? []) {
rejectForbiddenDocsCopy(`answer "${id}" link "${link.href}"`, link.label, errors);
}
}
}
+26 -26
View File
@@ -145,8 +145,8 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
body: "Do not mix bodies or response envelopes between these two surfaces.",
choices: [
{
id: "legacy",
label: "Legacy EAN batch",
id: "ean",
label: "EAN batch",
description: "POST /products/process · { data: { process_id } }",
nextId: "answer.process.legacy"
},
@@ -300,7 +300,7 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
endpoints: [
endpoint("GET", "/health", "Liveness (also /healthz and /readyz on the API host)"),
endpoint("GET", "/openapi.yaml", "This OpenAPI document"),
endpoint("GET", "/products", "List products (legacy { data, meta } envelope)")
endpoint("GET", "/products", "List products ({ data, meta } envelope)")
],
links: [
{ label: "Settings → API keys", href: "/settings?tab=api-keys", kind: "app" },
@@ -325,7 +325,7 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
endpoints: [
endpoint("GET", "/health", "No key required — confirm the viewer reaches the API"),
endpoint("GET", "/products", "Authenticated smoke list (page=1&limit=1)"),
endpoint("POST", "/products/process", "Legacy process — after Authorize"),
endpoint("POST", "/products/process", "EAN batch — after Authorize"),
endpoint("POST", "/process", "Jobs process — after Authorize")
],
links: [
@@ -417,20 +417,20 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
"answer.process.legacy": {
kind: "answer",
id: "answer.process.legacy",
pathKey: "process.legacy",
pathKey: "process.ean",
synonyms: ["ean", "items[].ean", "products/process"],
title: "Legacy public process (EAN batch)",
outcome: "Start and poll enrichment using the legacy Descrybe contract.",
title: "Public process (EAN batch)",
outcome: "Start and poll enrichment using POST/GET /products/process.",
body: "POST body uses items[].ean (preferred). Handler also accepts raw_product_ids as an alternate on this same path. Responses use HTTP 200 with { data: { process_id, … } } and completed items[] when done.",
authNote: AUTH_NOTE,
endpoints: [
endpoint("POST", "/products/process", "Start processing by EAN (legacy public contract)"),
endpoint("POST", "/products/process", "Start processing by EAN"),
endpoint("GET", "/products/process/{id}", "Processing job status by process_id")
],
links: [
{ label: "Legacy start in docs", href: docsOperationHref("POST", "/products/process"), kind: "docs" },
{ label: "Start in docs", href: docsOperationHref("POST", "/products/process"), kind: "docs" },
{
label: "Legacy status in docs",
label: "Status in docs",
href: docsOperationHref("GET", "/products/process/{id}"),
kind: "docs"
},
@@ -448,7 +448,7 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
synonyms: ["raw_product_ids", "cancel job", "retry job"],
title: "Dashboard-style processing jobs",
outcome: "Start flat JSON jobs by raw_product_ids; poll, cancel, or retry.",
body: "Same body shape as dashboard POST /api/processing/jobs. Start/retry return 202 Accepted with flat JSON (no data wrapper). Prefer /products/process for legacy integrations.",
body: "Same body shape as dashboard POST /api/processing/jobs. Start/retry return 202 Accepted with flat JSON (no data wrapper). Prefer /products/process for EAN-batch integrations.",
authNote: AUTH_NOTE,
endpoints: [
endpoint("POST", "/process", "Start processing job by raw_product_ids"),
@@ -473,15 +473,15 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
pathKey: "process.which",
synonyms: ["which process", "dual contract"],
title: "Which process API should I use?",
outcome: "Pick legacy EAN for integrations; pick /process for dashboard-style jobs.",
body: "1) Legacy public: POST/GET /products/process — items[].ean, HTTP 200 { data: { process_id } }. 2) Jobs: POST/GET /process — raw_product_ids, flat JSON, 202 on start. Mixing envelopes is the most common integration bug.",
outcome: "Pick EAN batch for integrations; pick /process for dashboard-style jobs.",
body: "1) Public process: POST/GET /products/process — items[].ean, HTTP 200 { data: { process_id } }. 2) Jobs: POST/GET /process — raw_product_ids, flat JSON, 202 on start. Mixing envelopes is the most common integration bug.",
authNote: AUTH_NOTE,
endpoints: [
endpoint("POST", "/products/process", "Legacy EAN batch"),
endpoint("POST", "/products/process", "EAN batch"),
endpoint("POST", "/process", "Dashboard-style jobs")
],
links: [
{ label: "Legacy process", href: docsOperationHref("POST", "/products/process"), kind: "docs" },
{ label: "EAN process", href: docsOperationHref("POST", "/products/process"), kind: "docs" },
{ label: "Jobs process", href: docsOperationHref("POST", "/process"), kind: "docs" },
{
label: "Dual-mode overview",
@@ -575,13 +575,13 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
endpoints: [
endpoint("GET", "/categories", "List categories"),
endpoint("POST", "/categories", "Create category"),
endpoint("POST", "/categories/create", "Create category (legacy alias)"),
endpoint("POST", "/categories/create", "Create category (alias of POST /categories)"),
endpoint("GET", "/categories/{id}", "Get category by UUID"),
endpoint("PATCH", "/categories/{id}", "Update category"),
endpoint("DELETE", "/categories/{id}", "Delete category by unique_id"),
endpoint("GET", "/attributes", "List attributes"),
endpoint("POST", "/attributes", "Create attribute"),
endpoint("POST", "/attributes/create", "Create attribute (legacy alias)"),
endpoint("POST", "/attributes/create", "Create attribute (alias of POST /attributes)"),
endpoint("PATCH", "/attributes/{id}", "Update attribute"),
endpoint("DELETE", "/attributes/{id}", "Delete attribute")
],
@@ -592,7 +592,7 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
{ label: "Attributes tag", href: docsTagHref("Attributes"), kind: "docs" },
{ label: "Authorize", href: docsAuthHref(), kind: "docs" }
],
tip: "Prefer canonical POST /categories and POST /attributes; /create aliases exist for legacy clients.",
tip: "Prefer canonical POST /categories and POST /attributes; /create aliases exist for existing integrations.",
relatedIds: ["answer.feeds.mapping", "answer.products"]
},
@@ -615,7 +615,7 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
{ label: "Try GET /products", href: docsOperationHref("GET", "/products"), kind: "docs" },
{ label: "Authorize", href: docsAuthHref(), kind: "docs" }
],
tip: "List uses legacy query params: page, limit, status, search, sortBy, sortOrder, feedId.",
tip: "List uses query params: page, limit, status, search, sortBy, sortOrder, feedId.",
relatedIds: ["answer.products.quality", "answer.process.legacy"]
},
@@ -648,13 +648,13 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
synonyms: ["campaigns", "calendar", "seasonal"],
title: "Marketing calendar (API)",
outcome: "List seasonal presets and prepare a calendar export via the public API.",
body: "Public /api/v1/marketing/calendar* (and legacy /campaigns aliases) are API-key routes for the content calendar. They are separate from session email campaigns under /api/campaigns in the dashboard UI.",
body: "Public /api/v1/marketing/calendar* (and /campaigns aliases) are API-key routes for the content calendar. They are separate from session email campaigns under /api/campaigns in the dashboard UI.",
authNote: AUTH_NOTE,
endpoints: [
endpoint("GET", "/marketing/calendar", "List seasonal campaign presets"),
endpoint("POST", "/marketing/calendar/prepare", "Prepare seasonal campaign export"),
endpoint("GET", "/campaigns", "List presets (legacy alias)"),
endpoint("POST", "/campaigns/prepare", "Prepare export (legacy alias)")
endpoint("GET", "/campaigns", "List presets (alias of GET /marketing/calendar)"),
endpoint("POST", "/campaigns/prepare", "Prepare export (alias of POST /marketing/calendar/prepare)")
],
links: [
{
@@ -684,7 +684,7 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
{ label: "Settings", href: "/settings", kind: "app" },
{ label: "Contact support", href: "/support/new", kind: "support" }
],
tip: "After upgrading, retry the same process endpoint you already chose (legacy vs jobs).",
tip: "After upgrading, retry the same process endpoint you already chose (EAN batch vs jobs).",
relatedIds: ["answer.error.job", "answer.process.chooser"]
},
@@ -722,7 +722,7 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
authNote: AUTH_NOTE,
endpoints: [
endpoint("POST", "/feeds/{id}/sync", "Sync (rate-limited)"),
endpoint("POST", "/products/process", "Legacy process (rate-limited)"),
endpoint("POST", "/products/process", "EAN process (rate-limited)"),
endpoint("POST", "/process", "Jobs process (rate-limited)"),
endpoint("POST", "/export-feeds/{id}/generate", "Generate export (rate-limited)")
],
@@ -741,10 +741,10 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
synonyms: ["job failed", "pending", "cancel", "retry"],
title: "Process job pending or failed",
outcome: "Poll the matching status endpoint; cancel/retry only on the /process job surface.",
body: "Legacy: GET /products/process/{id} (data envelope). Jobs: GET /process/{id} plus POST …/cancel|terminate|retry. Confirm you started the job on the same contract you are polling.",
body: "EAN batch: GET /products/process/{id} (data envelope). Jobs: GET /process/{id} plus POST …/cancel|terminate|retry. Confirm you started the job on the same contract you are polling.",
authNote: AUTH_NOTE,
endpoints: [
endpoint("GET", "/products/process/{id}", "Legacy poll"),
endpoint("GET", "/products/process/{id}", "EAN batch poll"),
endpoint("GET", "/process/{id}", "Jobs poll"),
endpoint("POST", "/process/{id}/retry", "Retry failed/cancelled job")
],
+33
View File
@@ -56,6 +56,7 @@ export function readStoredTryItKey(companyId: string): DocsTryItStoredKey | null
export function writeStoredTryItKey(entry: DocsTryItStoredKey): void {
if (typeof sessionStorage === "undefined") return;
try {
// Tab-scoped only. Never log the full key or send it to GTM/analytics.
sessionStorage.setItem(storageKey(entry.companyId), JSON.stringify(entry));
} catch {
/* quota / private mode */
@@ -89,6 +90,38 @@ export function clearRapiDocApiKeys(el: RapiDocAuthElement | null | undefined):
el.removeAllSecurityKeys();
}
/**
* RapiDoc labels required-but-empty credentials as "(None Applied)".
* Public /api/v1 ops use a company API key show that instead.
*/
export function rapiDocAuthEmptyLabel(text: string): string {
return text.replace(/None Applied/g, "API Key");
}
export function relabelRapiDocAuthRequired(
el: HTMLElement | null | undefined
): MutationObserver | null {
const root = el?.shadowRoot;
if (!root) return null;
const apply = () => {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let node: Node | null = walker.nextNode();
while (node) {
const text = node.textContent;
if (text && text.includes("None Applied")) {
node.textContent = rapiDocAuthEmptyLabel(text);
}
node = walker.nextNode();
}
};
apply();
const observer = new MutationObserver(apply);
observer.observe(root, { subtree: true, childList: true, characterData: true });
return observer;
}
export function maskKeyPrefix(prefix: string | null | undefined): string {
const p = (prefix ?? "").trim();
return p ? `${p}\u2026` : "dk_\u2026";
+22 -22
View File
@@ -78,7 +78,7 @@ export const de: MessageDict = {
"auth.login.failed": "Anmeldung fehlgeschlagen",
"auth.login.passwordNotSet": "Dieses Konto benötigt noch ein Passwort. Öffnen Sie Ihren Einladungslink oder bitten Sie einen Admin, einen neuen auszustellen.",
"auth.login.setPasswordFirstTitle": "Zuerst Passwort festlegen:",
"auth.login.setPasswordFirstBody": "nutzen Sie den Einladungslink aus Ihrer E-Mail. Wenn der Link an eine alte Adresse ging (E-Mail-Drift), bitten Sie einen Unternehmens-Admin, eine neue Passwort-Einladung an {email} auszustellen.",
"auth.login.setPasswordFirstBody": "nutzen Sie den Einladungslink aus Ihrer E-Mail. Wenn Sie einen neuen Link brauchen, bitten Sie einen Unternehmens-Admin, einen Link zum Festlegen des Passworts an {email} auszustellen.",
"auth.login.yourEmail": "Ihre E-Mail",
"auth.login.platformAdminsReissue": "Plattform-Admins können erneut ausstellen unter",
"auth.login.adminUsersLink": "Admin → Benutzer",
@@ -144,9 +144,9 @@ export const de: MessageDict = {
"auth.invite.acceptFailed": "Einladung konnte nicht angenommen werden",
"auth.invite.setPasswordFailed": "Passwort konnte nicht festgelegt werden",
"auth.invite.expired": "Diese Einladung ist ungültig oder abgelaufen. Bitten Sie Ihren Unternehmens-Admin um eine neue Einladung und öffnen Sie den neuen Link (oder fügen Sie das neue Token unten ein).",
"auth.invite.setPasswordExpired": "Dieser Passwort-Link ist ungültig oder abgelaufen. Bitten Sie einen Unternehmens- oder Plattform-Admin um Neuausstellung und öffnen Sie den neuen Link (oder fügen Sie das neue Token unten ein).",
"auth.invite.setPasswordExpired": "Dieser Passwort-Link ist ungültig oder abgelaufen. Bitten Sie einen Unternehmens-Admin, einen Link zum Festlegen des Passworts an diese E-Mail auszustellen, und öffnen Sie den neuen Link (oder fügen Sie das neue Token unten ein).",
"auth.invite.emailMismatchDefault": "Sie sind mit einer anderen E-Mail angemeldet als diese Einladung.",
"auth.invite.expiredFooter": "Abgelaufener Link? Bitten Sie einen Admin um Neuausstellung — es gibt keine Self-Service-API zum erneuten Senden. Plattform-Admins:",
"auth.invite.expiredFooter": "Abgelaufener Link? Bitten Sie einen Unternehmens-Admin um Neuausstellung es gibt keine Self-Service-API zum erneuten Senden.",
"auth.invite.adminUsersLink": "Admin → Benutzer",
"auth.invite.doneTitle": "Sie sind im Team",
"auth.invite.doneSetPasswordTitle": "Passwort gespeichert",
@@ -163,7 +163,7 @@ export const de: MessageDict = {
"auth.invite.switchAccountTitle": "Konto wechseln:",
"auth.invite.switchAccountBody": "melden Sie sich ab und schließen Sie dieses Formular mit der eingeladenen E-Mail{emailSuffix} ab.",
"auth.invite.reissueTitle": "Neuausstellungs-Pfad:",
"auth.invite.reissueBody": "wenn sich Ihre echte Anmelde-E-Mail geändert hat (E-Mail-Drift), bitten Sie einen Unternehmens-Admin, diese Einladung zu widerrufen und eine neue an die E-Mail zu senden, mit der Sie sich anmelden. Plattform-Admins können Passwort-Links auch unter Admin → Benutzer erneut ausstellen.",
"auth.invite.reissueBody": "bitten Sie einen Unternehmens-Admin, einen Link zum Festlegen des Passworts an diese E-Mail auszustellen oder diese Einladung zu widerrufen und eine neue an die E-Mail zu senden, mit der Sie sich anmelden.",
"locale.switcher": "Oberflächensprache",
"locale.menu": "Oberflächensprache wählen",
"locale.label": "Dashboard-Sprache",
@@ -276,7 +276,7 @@ export const de: MessageDict = {
"settings.table.lastUsed": "Zuletzt verwendet",
"dashboard.demoSandbox": "Demo-Sandbox",
"dashboard.demoEmptyTitle": "Demo-Sandbox ist leer",
"dashboard.demoEmptyHint": "Demo-Sandbox ist leer — wechseln Sie zu A1 oder verbinden Sie einen Feed, um echte Katalogstatistiken zu sehen.",
"dashboard.demoEmptyHint": "Demo-Sandbox ist leer verbinden Sie einen Feed, um echte Katalogstatistiken zu sehen.",
"dashboard.overviewTitle": "Übersicht",
"dashboard.quickLinks": "Schnelllinks",
"dashboard.whatsNew": "Neuigkeiten",
@@ -319,7 +319,7 @@ export const de: MessageDict = {
"dashboard.workflow.exportMap": "Zuordnen dann exportieren",
"dashboard.workflowHint": "Katalog rein → anreichern / verarbeiten → exportieren oder zu Shops pushen. Zum nächsten Schritt für {name}.",
"dashboard.overviewHint": "Live-Summen für {name}",
"dashboard.demoEmptyMessage": "Wechseln Sie in der Kopfzeile zu A1 (oder einem anderen Seed-Unternehmen) oder verbinden Sie hier einen Feed, um diese Sandbox zu füllen.",
"dashboard.demoEmptyMessage": "Verbinden Sie hier einen Feed, um diese Sandbox zu füllen, oder wechseln Sie in der Kopfzeile zu einem anderen Unternehmen.",
"dashboard.emptyTitle": "Katalog hereinholen",
"dashboard.emptyMessage": "Katalog rein → anreichern / verarbeiten → exportieren oder zu Shops pushen. Starten Sie mit Erste Schritte unten (oder Feed verbinden / CSV hochladen).",
"dashboard.connectFeedAnyway": "Feed trotzdem verbinden",
@@ -367,29 +367,29 @@ export const de: MessageDict = {
"activation.focus.sync": "Als Nächstes: Nach dem Speichern der Zuordnung eine Stichprobe synchronisieren. Sync nur bei zugeordneten Feeds — nicht bei unmapped Quellen.",
"activation.focus.dismiss": "Hinweis schließen",
"activation.dismiss": "Checkliste ausblenden",
"hypercare.report.title": "Cutover-Hypercare",
"hypercare.report.title": "Hilfe nach dem Umzug?",
"hypercare.report.message": "Fehlende oder falsche Daten nach der Migration? Melden Sie es, damit wir Ihren Workspace korrigieren können.",
"hypercare.report.cta": "Fehlende oder falsche Daten melden",
"hypercare.report.dismiss": "Verwerfen",
"hypercare.report.adminTriage": "Hypercare-Warteschlange öffnen",
"etl.gaps.title": "What did not migrate",
"etl.gaps.message": "These areas were intentionally left empty or partial after the Descrybe platform cutover. Nothing is “broken” — recreate or reconnect as needed.",
"etl.gaps.title": "What still needs setup",
"etl.gaps.message": "Some items weren't copied when we moved your workspace. Nothing is broken — recreate or reconnect as needed.",
"etl.gaps.dismiss": "Dismiss",
"etl.gaps.apiKeys.title": "API keys",
"etl.gaps.apiKeys.body": "Legacy API key secrets were not migrated. Create a new key to restore API access — the secret is shown only once.",
"etl.gaps.apiKeys.body": "Previous API keys weren't copied. Create a new key to restore API access the secret is shown only once.",
"etl.gaps.apiKeys.cta": "Open API keys",
"etl.gaps.blobs.title": "File contents (blobs)",
"etl.gaps.blobs.body": "Upload records may exist as metadata only. File bytes were not copied — re-upload CSVs or resync object storage if you need the originals.",
"etl.gaps.blobs.countHint": "{count} file records are metadata-only (bytes were not migrated).",
"etl.gaps.blobs.title": "Uploaded files",
"etl.gaps.blobs.body": "File names may appear even when the file itself wasn't copied. Re-upload CSVs if you need the originals.",
"etl.gaps.blobs.countHint": "{count} files are listed without their contents (they weren't copied).",
"etl.gaps.blobs.cta": "Open Files",
"etl.gaps.jobs.title": "Processing history",
"etl.gaps.jobs.body": "Legacy job and task history was not backfilled for cutover. New jobs you start here will appear normally.",
"etl.gaps.jobs.emptyHint": "Optional jobs domain was not run — empty history is expected.",
"etl.gaps.jobs.migratedHint": "{count} jobs were tagged migrated from an optional backfill.",
"etl.gaps.jobs.body": "Earlier job history wasn't imported. New jobs you start here will appear normally.",
"etl.gaps.jobs.emptyHint": "Processing history wasn't imported — an empty list is expected.",
"etl.gaps.jobs.migratedHint": "{count} jobs were imported from the previous workspace.",
"etl.gaps.jobs.cta": "Open Processing",
"etl.gaps.woo.title": "Store credentials",
"etl.gaps.woo.body": "WooCommerce and Shopify secrets may need to be re-entered after cutover — that is a reconnect step, not a broken sync.",
"etl.gaps.woo.body": "WooCommerce and Shopify passwords may need to be re-entered after the move — reconnect your store; sync is not broken.",
"etl.gaps.woo.cta": "Open Stores",
"etl.gaps.settings.title": "Company settings",
"etl.gaps.settings.body": "Only language and merge-products settings were imported. Re-check other company preferences in Settings.",
@@ -456,9 +456,9 @@ export const de: MessageDict = {
"errors.readOnly": "Das System ist im Nur-Lesen-Modus. Änderungen sind vorübergehend deaktiviert.",
"errors.companyAdminDenied": "Nur Unternehmens-Admins können {action}. Bitten Sie einen Admin um Hilfe.",
"errors.companyAdminDenied.actionDefault": "dies tun",
"docs.tryIt.memberCannotCreate": "Nur Unternehmens-Admins können API-Schlüssel erstellen. Legacy-Schlüssel wurden nicht migriert — Schlüssel unten einfügen oder Admin um einen neuen bitten.",
"docs.tryIt.noKeysLegacyAdmin": "Legacy-API-Schlüssel wurden nicht migriert. Neuen Schlüssel über Use my API key oder Einstellungen → API-Schlüssel erstellen — Geheimnis nur einmal sichtbar.",
"docs.tryIt.noKeysLegacyMember": "Keine API-Schlüssel gelistet. Legacy-Schlüssel nicht migriert — Admin um Erstellung bitten oder dk_-Schlüssel einfügen.",
"docs.tryIt.memberCannotCreate": "Nur Unternehmens-Admins können API-Schlüssel erstellen. Schlüssel wurden nicht migriert — Schlüssel unten einfügen oder Admin um einen neuen bitten.",
"docs.tryIt.noKeysLegacyAdmin": "API-Schlüssel wurden nicht migriert. Neuen Schlüssel über Use my API key oder Einstellungen → API-Schlüssel erstellen — Geheimnis nur einmal sichtbar.",
"docs.tryIt.noKeysLegacyMember": "Keine API-Schlüssel gelistet. Schlüssel nicht migriert — Admin um Erstellung bitten oder dk_-Schlüssel einfügen.",
"errors.couldNotUpdateLanguage": "Sprache konnte nicht aktualisiert werden",
"toast.support.replyTitle": "Support-Antwort",
"toast.support.replyBody": "Das Team hat auf Ihr Support-Ticket geantwortet.",
@@ -5065,7 +5065,7 @@ export const de: MessageDict = {
"brand.uploadLogo": "Logo hochladen",
"brand.clear": "Löschen",
"brand.field.logoUrl": "Logo-URL",
"brand.placeholder.logoUrl": "https://… oder hochgeladen /api/brand/logo/files/…",
"brand.placeholder.logoUrl": "https://",
"brand.logoAlt": "Vorschau des Markenlogos",
"brand.tipsTitle": "Formel- / Vorschautipps",
"brand.saving": "Wird gespeichert…",
@@ -5291,7 +5291,7 @@ export const de: MessageDict = {
"pricing.calc.enterpriseTitle": "Sounds like Enterprise",
"pricing.calc.enterpriseBody": "Your feeds or catalog size exceed self-serve caps. Talk to sales for unlimited capacity and a managed AI grant or BYOK.",
"pricing.calc.ctaPlan": "Choose {name}",
"pricing.calc.disclaimer": "Estimates use list prices and ~2 credits per AI product enhance. Checkout prices come from Stripe. A1 and other client deals stay admin-only.",
"pricing.calc.disclaimer": "Estimates use list prices and ~2 credits per AI product enhance. Checkout prices come from Stripe. Custom client deals stay admin-only.",
"pricing.card.mostPopular": "Am beliebtesten",
"pricing.card.custom": "Individuell",
"pricing.card.forever": "für immer",
+37 -28
View File
@@ -78,7 +78,7 @@ export const en: MessageDict = {
"auth.login.failed": "Login failed",
"auth.login.passwordNotSet": "This account still needs a password. Open your invite link, or ask an admin to re-issue one.",
"auth.login.setPasswordFirstTitle": "Set password first:",
"auth.login.setPasswordFirstBody": "use the invite link from your email. If the link went to an old address (email drift), ask a company admin to re-issue a set-password invite to {email}.",
"auth.login.setPasswordFirstBody": "use the invite link from your email. If you need a new link, ask a company admin to re-issue a set-password link to {email}.",
"auth.login.yourEmail": "your email",
"auth.login.platformAdminsReissue": "Platform admins can re-issue from",
"auth.login.adminUsersLink": "Admin → Users",
@@ -144,9 +144,9 @@ export const en: MessageDict = {
"auth.invite.acceptFailed": "Could not accept invite",
"auth.invite.setPasswordFailed": "Could not set password",
"auth.invite.expired": "This invite is invalid or expired. Ask your company admin to send a new invite, then open the new link (or paste the new token below).",
"auth.invite.setPasswordExpired": "This set-password link is invalid or expired. Ask a company or platform admin to re-issue it, then open the new link (or paste the new token below).",
"auth.invite.setPasswordExpired": "This set-password link is invalid or expired. Ask a company admin to re-issue a set-password link to this email, then open the new link (or paste the new token below).",
"auth.invite.emailMismatchDefault": "You're signed in as a different email than this invite.",
"auth.invite.expiredFooter": "Expired link? Ask an admin to re-issue — there is no self-serve resend API. Platform admins:",
"auth.invite.expiredFooter": "Expired link? Ask a company admin to re-issue — there is no self-serve resend API.",
"auth.invite.adminUsersLink": "Admin → Users",
"auth.invite.doneTitle": "You're on the team",
"auth.invite.doneSetPasswordTitle": "Password saved",
@@ -163,7 +163,7 @@ export const en: MessageDict = {
"auth.invite.switchAccountTitle": "Switch account:",
"auth.invite.switchAccountBody": "sign out, then finish this form with the invited email{emailSuffix}.",
"auth.invite.reissueTitle": "Re-issue path:",
"auth.invite.reissueBody": "if your real login email changed (email drift), ask a company admin to revoke this invite and send a new one to the email you use to sign in. Platform admins can also re-issue set-password links from Admin → Users.",
"auth.invite.reissueBody": "ask a company admin to re-issue a set-password link to this email, or to revoke this invite and send a new one to the email you use to sign in.",
"locale.switcher": "Interface language",
"locale.menu": "Choose interface language",
"locale.label": "Dashboard language",
@@ -296,7 +296,7 @@ export const en: MessageDict = {
"settings.table.lastUsed": "Last Used",
"dashboard.demoSandbox": "Demo sandbox",
"dashboard.demoEmptyTitle": "Demo sandbox is empty",
"dashboard.demoEmptyHint": "Demo sandbox is empty — switch to A1 or connect a feed to see real catalog stats.",
"dashboard.demoEmptyHint": "Demo sandbox is empty — connect a feed to see catalog stats.",
"dashboard.overviewTitle": "Overview",
"dashboard.quickLinks": "Quick links",
"dashboard.whatsNew": "What's new",
@@ -339,7 +339,7 @@ export const en: MessageDict = {
"dashboard.workflow.exportMap": "Map then export",
"dashboard.workflowHint": "Catalog in → enrich / process → export or push to stores. Jump to the next step for {name}.",
"dashboard.overviewHint": "Live totals for {name}",
"dashboard.demoEmptyMessage": "Switch to A1 (or another seeded company) in the header, or connect a feed here to populate this sandbox.",
"dashboard.demoEmptyMessage": "Connect a feed here to populate this sandbox, or switch to another company in the header.",
"dashboard.emptyTitle": "Bring a catalog in",
"dashboard.emptyMessage": "Catalog in → enrich / process → export or push to stores. Start with Getting started below (or connect a feed / upload a CSV).",
"dashboard.connectFeedAnyway": "Connect feed anyway",
@@ -391,28 +391,28 @@ export const en: MessageDict = {
"activation.focus.sync": "Next: Sync a sample after mappings are saved. Use Sync on a mapped feed — avoid syncing unmapped sources.",
"activation.focus.dismiss": "Dismiss hint",
"activation.dismiss": "Dismiss checklist",
"hypercare.report.title": "Cutover hypercare",
"hypercare.report.message": "See missing or wrong data after migration? Report it so we can fix your workspace.",
"hypercare.report.title": "Need help after the move?",
"hypercare.report.message": "See missing or wrong data after the move? Report it so we can fix your workspace.",
"hypercare.report.cta": "Report missing or wrong data",
"hypercare.report.dismiss": "Dismiss",
"hypercare.report.adminTriage": "Open hypercare queue",
"etl.gaps.title": "What did not migrate",
"etl.gaps.message": "These areas were intentionally left empty or partial after the Descrybe platform cutover. Nothing is broken — recreate or reconnect as needed.",
"etl.gaps.title": "What still needs setup",
"etl.gaps.message": "Some items weren't copied when we moved your workspace. Nothing is broken — recreate or reconnect as needed.",
"etl.gaps.dismiss": "Dismiss",
"etl.gaps.apiKeys.title": "API keys",
"etl.gaps.apiKeys.body": "Legacy API key secrets were not migrated. Create a new key to restore API access — the secret is shown only once.",
"etl.gaps.apiKeys.body": "Previous API keys weren't copied. Create a new key to restore API access — the secret is shown only once.",
"etl.gaps.apiKeys.cta": "Open API keys",
"etl.gaps.blobs.title": "File contents (blobs)",
"etl.gaps.blobs.body": "Upload records may exist as metadata only. File bytes were not copied — re-upload CSVs or resync object storage if you need the originals.",
"etl.gaps.blobs.countHint": "{count} file records are metadata-only (bytes were not migrated).",
"etl.gaps.blobs.title": "Uploaded files",
"etl.gaps.blobs.body": "File names may appear even when the file itself wasn't copied. Re-upload CSVs if you need the originals.",
"etl.gaps.blobs.countHint": "{count} files are listed without their contents (they weren't copied).",
"etl.gaps.blobs.cta": "Open Files",
"etl.gaps.jobs.title": "Processing history",
"etl.gaps.jobs.body": "Legacy job and task history was not backfilled for cutover. New jobs you start here will appear normally.",
"etl.gaps.jobs.emptyHint": "Optional jobs domain was not run — empty history is expected.",
"etl.gaps.jobs.migratedHint": "{count} jobs were tagged migrated from an optional backfill.",
"etl.gaps.jobs.body": "Earlier job history wasn't imported. New jobs you start here will appear normally.",
"etl.gaps.jobs.emptyHint": "Processing history wasn't imported — an empty list is expected.",
"etl.gaps.jobs.migratedHint": "{count} jobs were imported from the previous workspace.",
"etl.gaps.jobs.cta": "Open Processing",
"etl.gaps.woo.title": "Store credentials",
"etl.gaps.woo.body": "WooCommerce and Shopify secrets may need to be re-entered after cutoverthat is a reconnect step, not a broken sync.",
"etl.gaps.woo.body": "WooCommerce and Shopify passwords may need to be re-entered after the move — reconnect your store; sync is not broken.",
"etl.gaps.woo.cta": "Open Stores",
"etl.gaps.settings.title": "Company settings",
"etl.gaps.settings.body": "Only language and merge-products settings were imported. Re-check other company preferences in Settings.",
@@ -479,9 +479,9 @@ export const en: MessageDict = {
"errors.readOnly": "The system is in read-only mode. Changes are temporarily disabled.",
"errors.companyAdminDenied": "Only company admins can {action}. Ask a company admin for help.",
"errors.companyAdminDenied.actionDefault": "do this",
"docs.tryIt.memberCannotCreate": "Only company admins can create API keys. Legacy keys were not migrated — paste a key below, or ask an admin to create a new one.",
"docs.tryIt.noKeysLegacyAdmin": "Legacy API keys were not migrated. Create a new key via Use my API key or Settings → API keys — the secret is shown only once.",
"docs.tryIt.noKeysLegacyMember": "No API keys listed. Legacy keys were not migrated — ask a company admin to create one, or paste a dk_ key.",
"docs.tryIt.memberCannotCreate": "Only company admins can create API keys. Keys from the previous platform were not migrated — paste a key below, or ask an admin to create a new one.",
"docs.tryIt.noKeysLegacyAdmin": "API keys from the previous platform were not migrated. Create a new key via Use my API key or Settings → API keys — the secret is shown only once.",
"docs.tryIt.noKeysLegacyMember": "No API keys listed. Keys from the previous platform were not migrated — ask a company admin to create one, or paste a dk_ key.",
"errors.couldNotUpdateLanguage": "Could not update language",
"toast.support.replyTitle": "Support reply",
"toast.support.replyBody": "Staff replied to your support ticket.",
@@ -3729,6 +3729,7 @@ export const en: MessageDict = {
"processing.receipt.cancelledDesc": "The just-started job was cancelled.",
"processing.receipt.cancelFailed": "Could not cancel processing job",
"unsubscribe.title": "Unsubscribe",
"unsubscribe.useEmailLink": "Use the unsubscribe link in the email we sent you. Opening this page without that link cannot change your email preferences.",
"unsubscribe.me": "Unsubscribe me",
"unsubscribe.invalidLink": "Invalid link",
"unsubscribe.failedShort": "Unsubscribe failed",
@@ -4445,23 +4446,23 @@ export const en: MessageDict = {
"categories.promptSection.navHeading": "Sections",
"categories.promptSection.navHint": "Edit one part of the enhance reply at a time.",
"categories.promptSection.title": "Title",
"categories.promptSection.titleHelp": "Instructions for the product title (JSON name).",
"categories.promptSection.titleHelp": "Instructions for the product title.",
"categories.promptSection.titleFormulaNote": "Title formula slots (type + brand + model) are appended automatically when set on this category.",
"categories.promptSection.description": "Description",
"categories.promptSection.descriptionHelp": "Instructions for the HTML product body (JSON description - not SEO meta).",
"categories.promptSection.descriptionHelp": "Instructions for the HTML product body (not SEO meta).",
"categories.promptSection.descriptionFormulaNote": "Description formula sections are appended at render time when configured.",
"categories.promptSection.meta": "Meta",
"categories.promptSection.metaHelp": "Instructions for SEO meta_title and meta_description (plain text).",
"categories.promptSection.metaHelp": "Instructions for the SEO title and description (plain text).",
"categories.promptSection.metaFormulaNote": "SEO meta formulas from the description template are appended when present.",
"categories.promptSection.attributes": "Attributes",
"categories.promptSection.attributesHelp": "Instructions for the attrs object (allowlisted keys).",
"categories.promptSection.attributesHelp": "Instructions for product attributes (allowed keys only).",
"categories.promptSection.attributesFormulaNote": "Allowed attribute keys and title-formula attr slots are appended at render time.",
"categories.promptSection.schemaHeading": "One-shot JSON",
"categories.promptSection.schemaHelp": "Enhance returns a single JSON object. This section owns the keys below.",
"categories.promptSection.contentLabel": "Section instructions",
"categories.promptSection.contentPlaceholder": "Role guidance and placeholders for this section...",
"categories.promptSection.preambleToggle": "Shared intro (all sections)",
"categories.promptSection.preambleHelp": "Shown once above the section markers. Keep the JSON schema reminder so the model replies correctly.",
"categories.promptSection.preambleHelp": "Shown once above the section markers. Keep language and evidence reminders so the model replies correctly.",
"categories.promptSection.clearLanguage": "Clear language override",
"categories.promptSection.unstructuredHint": "This prompt is not sectioned yet. Content is shown under Description - edit the sections you need and save to store the standard Title / Description / Meta / Attributes layout.",
"categories.promptSection.categorizeNote": "Categorize (taxonomy pick) is a separate pipeline step - not part of this enhance prompt.",
@@ -4792,6 +4793,10 @@ export const en: MessageDict = {
"seo.terms.description": "Terms for using Descrybe — feed import, catalog enrichment, AI-assisted content, exports, and WooCommerce sync for your business.",
"seo.features.title": "Features — Feeds, enrichment, and export | Descrybe",
"seo.features.description": "See how Descrybe imports supplier feeds, maps fields to your taxonomy, enriches product data, and ships catalogs via export feeds, WooCommerce, or API.",
"seo.contactSales.title": "Contact sales | Descrybe",
"seo.contactSales.description": "Tell us about your catalog and capacity needs — we will prepare a custom plan you can pay online.",
"seo.unsubscribe.title": "Unsubscribe | Descrybe",
"seo.unsubscribe.description": "Stop marketing emails from Descrybe using the unsubscribe link in the message we sent you.",
"pricing.page.eyebrow": "Pricing",
"pricing.page.title": "Simple plans for growing catalogs",
"pricing.page.lead": "Start free with feed mapping and basic cleanup. Upgrade when you need AI titles and descriptions, more products, export feeds, or WooCommerce sync — Starter through Enterprise.",
@@ -4799,6 +4804,8 @@ export const en: MessageDict = {
"pricing.page.subscribedMid": "or compare plans in",
"pricing.page.plansLink": "Plans",
"pricing.page.subscribedAfter": ".",
"pricing.page.guestSubscribedBefore": "Already subscribed?",
"pricing.page.guestSubscribedAfter": "to manage billing and compare plans.",
"legal.lastUpdated": "Last updated: {date}",
"legal.backHome": "← Back to Home",
"legal.emailLabel": "Email:",
@@ -5130,7 +5137,7 @@ export const en: MessageDict = {
"brand.uploadLogo": "Upload logo",
"brand.clear": "Clear",
"brand.field.logoUrl": "Logo URL",
"brand.placeholder.logoUrl": "https://… or uploaded /api/brand/logo/files/…",
"brand.placeholder.logoUrl": "https://…",
"brand.logoAlt": "Brand logo preview",
"brand.tipsTitle": "Formula / preview tips",
"brand.saving": "Saving…",
@@ -5291,6 +5298,8 @@ export const en: MessageDict = {
"pricing.section.publicBefore": "Public plans: Free, Starter, Plus, Growth, Business, Scale, and Enterprise. Create a Free account, then upgrade under",
"pricing.section.publicOr": "or",
"pricing.section.publicAfter": "via Stripe Checkout. Enterprise remains sales-led.",
"pricing.section.guestPublicBefore": "Public plans: Free, Starter, Plus, Growth, Business, Scale, and Enterprise. Create a Free account, then",
"pricing.section.guestPublicAfter": "to upgrade via Stripe Checkout. Enterprise remains sales-led.",
"pricing.section.plansLink": "Plans",
"pricing.section.billingLink": "Billing",
"pricing.section.capabilitiesTitle": "What every plan is built for",
@@ -5359,7 +5368,7 @@ export const en: MessageDict = {
"pricing.calc.enterpriseTitle": "Sounds like Enterprise",
"pricing.calc.enterpriseBody": "Your feeds or catalog size exceed self-serve caps. Talk to sales for unlimited capacity and a managed AI grant or BYOK.",
"pricing.calc.ctaPlan": "Choose {name}",
"pricing.calc.disclaimer": "Estimates use list prices and ~2 credits per AI product enhance. Checkout prices come from Stripe. A1 and other client deals stay admin-only.",
"pricing.calc.disclaimer": "Estimates use list prices and ~2 credits per AI product enhance. Checkout prices come from Stripe. Custom client deals stay admin-only.",
"pricing.card.mostPopular": "Most Popular",
"pricing.card.custom": "Custom",
"pricing.card.forever": "forever",
+28 -19
View File
@@ -78,7 +78,7 @@ export const es: MessageDict = {
"auth.login.failed": "Error al iniciar sesión",
"auth.login.passwordNotSet": "Esta cuenta aún necesita una contraseña. Abre el enlace de invitación o pide a un administrador que emita uno nuevo.",
"auth.login.setPasswordFirstTitle": "Establece la contraseña primero:",
"auth.login.setPasswordFirstBody": "usa el enlace de invitación de tu correo. Si el enlace fue a una dirección antigua (cambio de correo), pide a un administrador de la empresa que emita una nueva invitación para establecer contraseña a {email}.",
"auth.login.setPasswordFirstBody": "usa el enlace de invitación de tu correo. Si necesitas un enlace nuevo, pide a un administrador de la empresa que reemita un enlace para establecer contraseña a {email}.",
"auth.login.yourEmail": "tu correo",
"auth.login.platformAdminsReissue": "Los administradores de plataforma pueden reemitir desde",
"auth.login.adminUsersLink": "Admin → Usuarios",
@@ -144,9 +144,9 @@ export const es: MessageDict = {
"auth.invite.acceptFailed": "No se pudo aceptar la invitación",
"auth.invite.setPasswordFailed": "No se pudo establecer la contraseña",
"auth.invite.expired": "Esta invitación no es válida o ha caducado. Pide a tu administrador de la empresa que envíe una nueva invitación y abre el nuevo enlace (o pega el nuevo token abajo).",
"auth.invite.setPasswordExpired": "Este enlace para establecer contraseña no es válido o ha caducado. Pide a un administrador de la empresa o de la plataforma que lo reemita y abre el nuevo enlace (o pega el nuevo token abajo).",
"auth.invite.setPasswordExpired": "Este enlace para establecer contraseña no es válido o ha caducado. Pide a un administrador de la empresa que reemita un enlace para establecer contraseña a este correo y abre el nuevo enlace (o pega el nuevo token abajo).",
"auth.invite.emailMismatchDefault": "Has iniciado sesión con un correo distinto al de esta invitación.",
"auth.invite.expiredFooter": "¿Enlace caducado? Pide a un administrador que lo reemita — no hay API de reenvío autoservicio. Administradores de plataforma:",
"auth.invite.expiredFooter": "¿Enlace caducado? Pide a un administrador de la empresa que lo reemita no hay API de reenvío autoservicio.",
"auth.invite.adminUsersLink": "Admin → Usuarios",
"auth.invite.doneTitle": "Ya formas parte del equipo",
"auth.invite.doneSetPasswordTitle": "Contraseña guardada",
@@ -163,7 +163,7 @@ export const es: MessageDict = {
"auth.invite.switchAccountTitle": "Cambiar de cuenta:",
"auth.invite.switchAccountBody": "cierra sesión y completa este formulario con el correo invitado{emailSuffix}.",
"auth.invite.reissueTitle": "Vía de reemisión:",
"auth.invite.reissueBody": "si cambió tu correo real de acceso (desfase de correo), pide a un administrador de la empresa que revoque esta invitación y envíe una nueva al correo con el que inicias sesión. Los administradores de plataforma también pueden reemitir enlaces para establecer contraseña desde Admin → Usuarios.",
"auth.invite.reissueBody": "pide a un administrador de la empresa que reemita un enlace para establecer contraseña a este correo, o que revoque esta invitación y envíe una nueva al correo con el que inicias sesión.",
"locale.switcher": "Idioma de la interfaz",
"locale.menu": "Elegir idioma de la interfaz",
"locale.label": "Idioma del panel",
@@ -276,7 +276,7 @@ export const es: MessageDict = {
"settings.table.lastUsed": "Último uso",
"dashboard.demoSandbox": "Zona de pruebas demo",
"dashboard.demoEmptyTitle": "La zona de pruebas demo está vacía",
"dashboard.demoEmptyHint": "La zona de pruebas demo está vacía — cambia a A1 o conecta un feed para ver estadísticas reales del catálogo.",
"dashboard.demoEmptyHint": "La zona de pruebas demo está vacía — conecta un feed para ver estadísticas reales del catálogo.",
"dashboard.overviewTitle": "Resumen",
"dashboard.quickLinks": "Enlaces rápidos",
"dashboard.whatsNew": "Novedades",
@@ -319,7 +319,7 @@ export const es: MessageDict = {
"dashboard.workflow.exportMap": "Mapear y exportar",
"dashboard.workflowHint": "Catálogo dentro → enriquecer / procesar → exportar o enviar a tiendas. Salta al siguiente paso para {name}.",
"dashboard.overviewHint": "Totales en vivo para {name}",
"dashboard.demoEmptyMessage": "Cambia a A1 (u otra empresa con datos) en el encabezado, o conecta un feed aquí para poblar esta zona de pruebas.",
"dashboard.demoEmptyMessage": "Conecta un feed aquí para poblar esta zona de pruebas, o cambia a otra empresa en el encabezado.",
"dashboard.emptyTitle": "Importa un catálogo",
"dashboard.emptyMessage": "Catálogo dentro → enriquecer / procesar → exportar o enviar a tiendas. Empieza con Primeros pasos abajo (o conecta un feed / sube un CSV).",
"dashboard.connectFeedAnyway": "Conectar feed de todos modos",
@@ -367,29 +367,29 @@ export const es: MessageDict = {
"activation.focus.sync": "Siguiente: sincroniza una muestra tras guardar el mapeo. Usa Sync en un feed mapeado — evita sincronizar orígenes sin mapear.",
"activation.focus.dismiss": "Cerrar aviso",
"activation.dismiss": "Descartar lista",
"hypercare.report.title": "Hypercare de corte",
"hypercare.report.title": "¿Necesitas ayuda tras el traslado?",
"hypercare.report.message": "¿Faltan o están incorrectos datos tras la migración? Repórtalo para que podamos corregir tu espacio de trabajo.",
"hypercare.report.cta": "Reportar datos faltantes o incorrectos",
"hypercare.report.dismiss": "Descartar",
"hypercare.report.adminTriage": "Abrir cola de hypercare",
"etl.gaps.title": "What did not migrate",
"etl.gaps.message": "These areas were intentionally left empty or partial after the Descrybe platform cutover. Nothing is “broken” — recreate or reconnect as needed.",
"etl.gaps.title": "What still needs setup",
"etl.gaps.message": "Some items weren't copied when we moved your workspace. Nothing is broken — recreate or reconnect as needed.",
"etl.gaps.dismiss": "Dismiss",
"etl.gaps.apiKeys.title": "API keys",
"etl.gaps.apiKeys.body": "Legacy API key secrets were not migrated. Create a new key to restore API access — the secret is shown only once.",
"etl.gaps.apiKeys.body": "Previous API keys weren't copied. Create a new key to restore API access the secret is shown only once.",
"etl.gaps.apiKeys.cta": "Open API keys",
"etl.gaps.blobs.title": "File contents (blobs)",
"etl.gaps.blobs.body": "Upload records may exist as metadata only. File bytes were not copied — re-upload CSVs or resync object storage if you need the originals.",
"etl.gaps.blobs.countHint": "{count} file records are metadata-only (bytes were not migrated).",
"etl.gaps.blobs.title": "Uploaded files",
"etl.gaps.blobs.body": "File names may appear even when the file itself wasn't copied. Re-upload CSVs if you need the originals.",
"etl.gaps.blobs.countHint": "{count} files are listed without their contents (they weren't copied).",
"etl.gaps.blobs.cta": "Open Files",
"etl.gaps.jobs.title": "Processing history",
"etl.gaps.jobs.body": "Legacy job and task history was not backfilled for cutover. New jobs you start here will appear normally.",
"etl.gaps.jobs.emptyHint": "Optional jobs domain was not run — empty history is expected.",
"etl.gaps.jobs.migratedHint": "{count} jobs were tagged migrated from an optional backfill.",
"etl.gaps.jobs.body": "Earlier job history wasn't imported. New jobs you start here will appear normally.",
"etl.gaps.jobs.emptyHint": "Processing history wasn't imported — an empty list is expected.",
"etl.gaps.jobs.migratedHint": "{count} jobs were imported from the previous workspace.",
"etl.gaps.jobs.cta": "Open Processing",
"etl.gaps.woo.title": "Store credentials",
"etl.gaps.woo.body": "WooCommerce and Shopify secrets may need to be re-entered after cutover — that is a reconnect step, not a broken sync.",
"etl.gaps.woo.body": "WooCommerce and Shopify passwords may need to be re-entered after the move — reconnect your store; sync is not broken.",
"etl.gaps.woo.cta": "Open Stores",
"etl.gaps.settings.title": "Company settings",
"etl.gaps.settings.body": "Only language and merge-products settings were imported. Re-check other company preferences in Settings.",
@@ -3690,6 +3690,7 @@ export const es: MessageDict = {
"processing.receipt.cancelledDesc": "El trabajo recién iniciado se canceló.",
"processing.receipt.cancelFailed": "No se pudo cancelar el trabajo de procesamiento",
"unsubscribe.title": "Cancelar suscripción",
"unsubscribe.useEmailLink": "Usa el enlace de baja del correo que te enviamos. Abrir esta pagina sin ese enlace no puede cambiar tus preferencias de email.",
"unsubscribe.me": "Cancelar mi suscripción",
"unsubscribe.invalidLink": "Enlace no válido",
"unsubscribe.failedShort": "Error al cancelar la suscripción",
@@ -4727,6 +4728,10 @@ export const es: MessageDict = {
"seo.terms.description": "Términos de uso de Descrybe: importación de feeds, enriquecimiento de catálogo, contenido asistido por IA, exportaciones y sincronización con WooCommerce para tu negocio.",
"seo.features.title": "Funciones — Feeds, enriquecimiento y exportación | Descrybe",
"seo.features.description": "Descubre cómo Descrybe importa feeds de proveedores, mapea campos a tu taxonomía, enriquece datos de producto y envía catálogos mediante feeds de exportación, WooCommerce o API.",
"seo.contactSales.title": "Contacto comercial | Descrybe",
"seo.contactSales.description": "Cuentanos sobre tu catalogo y necesidades de capacidad: prepararemos un plan personalizado que puedes pagar en linea.",
"seo.unsubscribe.title": "Cancelar suscripcion | Descrybe",
"seo.unsubscribe.description": "Deja de recibir emails de marketing de Descrybe usando el enlace de baja del mensaje que te enviamos.",
"pricing.page.eyebrow": "Precios",
"pricing.page.title": "Planes sencillos para catálogos en crecimiento",
"pricing.page.lead": "Empieza gratis con mapeo de feeds y limpieza básica. Mejora cuando necesites títulos y descripciones con IA, más productos, feeds de exportación o sincronización con WooCommerce — de Starter a Enterprise.",
@@ -4734,6 +4739,8 @@ export const es: MessageDict = {
"pricing.page.subscribedMid": "o compara planes en",
"pricing.page.plansLink": "Planes",
"pricing.page.subscribedAfter": ".",
"pricing.page.guestSubscribedBefore": "Ya estas suscrito?",
"pricing.page.guestSubscribedAfter": "para gestionar la facturacion y comparar planes.",
"legal.lastUpdated": "Última actualización: {date}",
"legal.backHome": "← Volver al inicio",
"legal.emailLabel": "Correo electrónico:",
@@ -5065,7 +5072,7 @@ export const es: MessageDict = {
"brand.uploadLogo": "Subir logo",
"brand.clear": "Borrar",
"brand.field.logoUrl": "URL del logo",
"brand.placeholder.logoUrl": "https://… o subido /api/brand/logo/files/…",
"brand.placeholder.logoUrl": "https://",
"brand.logoAlt": "Vista previa del logo de marca",
"brand.tipsTitle": "Consejos de fórmula / vista previa",
"brand.saving": "Guardando…",
@@ -5226,6 +5233,8 @@ export const es: MessageDict = {
"pricing.section.publicBefore": "Planes públicos: Free, Starter, Growth, Business y Enterprise. Crea una cuenta Free y luego mejora en",
"pricing.section.publicOr": "o",
"pricing.section.publicAfter": "mediante Stripe Checkout. Enterprise sigue siendo con ventas.",
"pricing.section.guestPublicBefore": "Planes publicos: Free, Starter, Plus, Growth, Business, Scale y Enterprise. Crea una cuenta Free y luego",
"pricing.section.guestPublicAfter": "para mejorar via Stripe Checkout. Enterprise sigue gestionado por ventas.",
"pricing.section.plansLink": "Planes",
"pricing.section.billingLink": "Facturación",
"pricing.section.capabilitiesTitle": "Para qué está pensado cada plan",
@@ -5291,7 +5300,7 @@ export const es: MessageDict = {
"pricing.calc.enterpriseTitle": "Sounds like Enterprise",
"pricing.calc.enterpriseBody": "Your feeds or catalog size exceed self-serve caps. Talk to sales for unlimited capacity and a managed AI grant or BYOK.",
"pricing.calc.ctaPlan": "Choose {name}",
"pricing.calc.disclaimer": "Estimates use list prices and ~2 credits per AI product enhance. Checkout prices come from Stripe. A1 and other client deals stay admin-only.",
"pricing.calc.disclaimer": "Estimates use list prices and ~2 credits per AI product enhance. Checkout prices come from Stripe. Custom client deals stay admin-only.",
"pricing.card.mostPopular": "Más popular",
"pricing.card.custom": "Personalizado",
"pricing.card.forever": "para siempre",
+28 -19
View File
@@ -78,7 +78,7 @@ export const fr: MessageDict = {
"auth.login.failed": "Échec de la connexion",
"auth.login.passwordNotSet": "Ce compte a encore besoin d'un mot de passe. Ouvrez votre lien d'invitation, ou demandez à un administrateur d'en émettre un nouveau.",
"auth.login.setPasswordFirstTitle": "Définissez d'abord le mot de passe :",
"auth.login.setPasswordFirstBody": "utilisez le lien d'invitation de votre e-mail. Si le lien a été envoyé à une ancienne adresse (dérive d'e-mail), demandez à un administrateur de l'entreprise de renvoyer une invitation de définition de mot de passe à {email}.",
"auth.login.setPasswordFirstBody": "utilisez le lien d'invitation de votre e-mail. Si vous avez besoin d'un nouveau lien, demandez à un administrateur de l'entreprise de réémettre un lien de définition de mot de passe à {email}.",
"auth.login.yourEmail": "votre e-mail",
"auth.login.platformAdminsReissue": "Les administrateurs de la plateforme peuvent réémettre depuis",
"auth.login.adminUsersLink": "Admin → Utilisateurs",
@@ -144,9 +144,9 @@ export const fr: MessageDict = {
"auth.invite.acceptFailed": "Impossible d'accepter l'invitation",
"auth.invite.setPasswordFailed": "Impossible de définir le mot de passe",
"auth.invite.expired": "Cette invitation est invalide ou expirée. Demandez à votre administrateur d'envoyer une nouvelle invitation, puis ouvrez le nouveau lien (ou collez le nouveau jeton ci-dessous).",
"auth.invite.setPasswordExpired": "Ce lien de définition de mot de passe est invalide ou expiré. Demandez à un administrateur de l'entreprise ou de la plateforme de le réémettre, puis ouvrez le nouveau lien (ou collez le nouveau jeton ci-dessous).",
"auth.invite.setPasswordExpired": "Ce lien de définition de mot de passe est invalide ou expiré. Demandez à un administrateur de l'entreprise de réémettre un lien de définition de mot de passe vers cet e-mail, puis ouvrez le nouveau lien (ou collez le nouveau jeton ci-dessous).",
"auth.invite.emailMismatchDefault": "Vous êtes connecté avec un e-mail différent de celui de cette invitation.",
"auth.invite.expiredFooter": "Lien expiré ? Demandez à un administrateur de le réémettre — il n'y a pas d'API de renvoi en libre-service. Administrateurs de plateforme :",
"auth.invite.expiredFooter": "Lien expiré ? Demandez à un administrateur de l'entreprise de le réémettre — il n'y a pas d'API de renvoi en libre-service.",
"auth.invite.adminUsersLink": "Admin → Utilisateurs",
"auth.invite.doneTitle": "Vous faites partie de l'équipe",
"auth.invite.doneSetPasswordTitle": "Mot de passe enregistré",
@@ -163,7 +163,7 @@ export const fr: MessageDict = {
"auth.invite.switchAccountTitle": "Changer de compte :",
"auth.invite.switchAccountBody": "déconnectez-vous, puis terminez ce formulaire avec l'e-mail invité{emailSuffix}.",
"auth.invite.reissueTitle": "Chemin de réémission :",
"auth.invite.reissueBody": "si votre vrai e-mail de connexion a changé (dérive d'e-mail), demandez à un administrateur de l'entreprise de révoquer cette invitation et d'en envoyer une nouvelle à l'e-mail que vous utilisez pour vous connecter. Les administrateurs de la plateforme peuvent aussi réémettre des liens de définition de mot de passe depuis Admin → Utilisateurs.",
"auth.invite.reissueBody": "demandez à un administrateur de l'entreprise de réémettre un lien de définition de mot de passe vers cet e-mail, ou de révoquer cette invitation et d'en envoyer une nouvelle à l'e-mail que vous utilisez pour vous connecter.",
"locale.switcher": "Langue de l'interface",
"locale.menu": "Choisir la langue de l'interface",
"locale.label": "Langue du tableau de bord",
@@ -276,7 +276,7 @@ export const fr: MessageDict = {
"settings.table.lastUsed": "Dernière utilisation",
"dashboard.demoSandbox": "Bac à sable démo",
"dashboard.demoEmptyTitle": "Le bac à sable démo est vide",
"dashboard.demoEmptyHint": "Le bac à sable démo est vide — basculez vers A1 ou connectez un flux pour voir de vraies stats catalogue.",
"dashboard.demoEmptyHint": "Le bac à sable démo est vide connectez un flux pour voir de vraies stats catalogue.",
"dashboard.overviewTitle": "Vue d'ensemble",
"dashboard.quickLinks": "Liens rapides",
"dashboard.whatsNew": "Nouveautés",
@@ -319,7 +319,7 @@ export const fr: MessageDict = {
"dashboard.workflow.exportMap": "Mapper puis exporter",
"dashboard.workflowHint": "Catalogue entrant → enrichir / traiter → exporter ou pousser vers les boutiques. Passez à l’étape suivante pour {name}.",
"dashboard.overviewHint": "Totaux en direct pour {name}",
"dashboard.demoEmptyMessage": "Basculez vers A1 (ou une autre entreprise seedée) dans l'en-tête, ou connectez un flux ici pour remplir ce bac à sable.",
"dashboard.demoEmptyMessage": "Connectez un flux ici pour remplir ce bac à sable, ou basculez vers une autre entreprise dans l'en-tête.",
"dashboard.emptyTitle": "Importez un catalogue",
"dashboard.emptyMessage": "Catalogue entrant → enrichir / traiter → exporter ou pousser vers les boutiques. Commencez avec Premiers pas ci-dessous (ou connectez un flux / téléversez un CSV).",
"dashboard.connectFeedAnyway": "Connecter un flux quand même",
@@ -367,29 +367,29 @@ export const fr: MessageDict = {
"activation.focus.sync": "Ensuite : synchronisez un échantillon après l’enregistrement du mapping. Utilisez Sync sur un flux mappé — évitez les sources non mappées.",
"activation.focus.dismiss": "Fermer l’indication",
"activation.dismiss": "Masquer la checklist",
"hypercare.report.title": "Hypercare de bascule",
"hypercare.report.title": "Besoin d'aide après le transfert ?",
"hypercare.report.message": "Données manquantes ou incorrectes après la migration ? Signalez-le pour que nous corrigions votre espace de travail.",
"hypercare.report.cta": "Signaler des données manquantes ou incorrectes",
"hypercare.report.dismiss": "Ignorer",
"hypercare.report.adminTriage": "Ouvrir la file hypercare",
"etl.gaps.title": "What did not migrate",
"etl.gaps.message": "These areas were intentionally left empty or partial after the Descrybe platform cutover. Nothing is “broken” — recreate or reconnect as needed.",
"etl.gaps.title": "What still needs setup",
"etl.gaps.message": "Some items weren't copied when we moved your workspace. Nothing is broken — recreate or reconnect as needed.",
"etl.gaps.dismiss": "Dismiss",
"etl.gaps.apiKeys.title": "API keys",
"etl.gaps.apiKeys.body": "Legacy API key secrets were not migrated. Create a new key to restore API access — the secret is shown only once.",
"etl.gaps.apiKeys.body": "Previous API keys weren't copied. Create a new key to restore API access the secret is shown only once.",
"etl.gaps.apiKeys.cta": "Open API keys",
"etl.gaps.blobs.title": "File contents (blobs)",
"etl.gaps.blobs.body": "Upload records may exist as metadata only. File bytes were not copied — re-upload CSVs or resync object storage if you need the originals.",
"etl.gaps.blobs.countHint": "{count} file records are metadata-only (bytes were not migrated).",
"etl.gaps.blobs.title": "Uploaded files",
"etl.gaps.blobs.body": "File names may appear even when the file itself wasn't copied. Re-upload CSVs if you need the originals.",
"etl.gaps.blobs.countHint": "{count} files are listed without their contents (they weren't copied).",
"etl.gaps.blobs.cta": "Open Files",
"etl.gaps.jobs.title": "Processing history",
"etl.gaps.jobs.body": "Legacy job and task history was not backfilled for cutover. New jobs you start here will appear normally.",
"etl.gaps.jobs.emptyHint": "Optional jobs domain was not run — empty history is expected.",
"etl.gaps.jobs.migratedHint": "{count} jobs were tagged migrated from an optional backfill.",
"etl.gaps.jobs.body": "Earlier job history wasn't imported. New jobs you start here will appear normally.",
"etl.gaps.jobs.emptyHint": "Processing history wasn't imported — an empty list is expected.",
"etl.gaps.jobs.migratedHint": "{count} jobs were imported from the previous workspace.",
"etl.gaps.jobs.cta": "Open Processing",
"etl.gaps.woo.title": "Store credentials",
"etl.gaps.woo.body": "WooCommerce and Shopify secrets may need to be re-entered after cutover — that is a reconnect step, not a broken sync.",
"etl.gaps.woo.body": "WooCommerce and Shopify passwords may need to be re-entered after the move — reconnect your store; sync is not broken.",
"etl.gaps.woo.cta": "Open Stores",
"etl.gaps.settings.title": "Company settings",
"etl.gaps.settings.body": "Only language and merge-products settings were imported. Re-check other company preferences in Settings.",
@@ -3690,6 +3690,7 @@ export const fr: MessageDict = {
"processing.receipt.cancelledDesc": "La tâche qui vient de démarrer a été annulée.",
"processing.receipt.cancelFailed": "Impossible d'annuler la tâche de traitement",
"unsubscribe.title": "Se désabonner",
"unsubscribe.useEmailLink": "Utilisez le lien de desabonnement dans l'e-mail que nous vous avons envoye. Ouvrir cette page sans ce lien ne peut pas modifier vos preferences e-mail.",
"unsubscribe.me": "Me désabonner",
"unsubscribe.invalidLink": "Lien invalide",
"unsubscribe.failedShort": "Échec du désabonnement",
@@ -4727,6 +4728,10 @@ export const fr: MessageDict = {
"seo.terms.description": "Conditions d'utilisation de Descrybe : import de flux, enrichissement de catalogue, contenu assisté par IA, exports et synchronisation WooCommerce pour votre entreprise.",
"seo.features.title": "Fonctionnalités — Flux, enrichissement et export | Descrybe",
"seo.features.description": "Découvrez comment Descrybe importe les flux fournisseurs, mappe les champs à votre taxonomie, enrichit les données produit et diffuse les catalogues via flux d'export, WooCommerce ou API.",
"seo.contactSales.title": "Contacter les ventes | Descrybe",
"seo.contactSales.description": "Parlez-nous de votre catalogue et de vos besoins de capacite: nous preparerons une offre personnalisee payable en ligne.",
"seo.unsubscribe.title": "Se desabonner | Descrybe",
"seo.unsubscribe.description": "Arretez les e-mails marketing Descrybe via le lien de desabonnement du message que nous vous avons envoye.",
"pricing.page.eyebrow": "Tarifs",
"pricing.page.title": "Des offres simples pour des catalogues en croissance",
"pricing.page.lead": "Commencez gratuitement avec le mapping de flux et un nettoyage de base. Passez à un plan supérieur quand vous avez besoin de titres et descriptions IA, de plus de produits, d’exports ou de la sync WooCommerce — de Starter à Enterprise.",
@@ -4734,6 +4739,8 @@ export const fr: MessageDict = {
"pricing.page.subscribedMid": "ou comparez les offres dans",
"pricing.page.plansLink": "Offres",
"pricing.page.subscribedAfter": ".",
"pricing.page.guestSubscribedBefore": "Deja abonne?",
"pricing.page.guestSubscribedAfter": "pour gerer la facturation et comparer les offres.",
"legal.lastUpdated": "Dernière mise à jour : {date}",
"legal.backHome": "← Retour à l’accueil",
"legal.emailLabel": "E-mail :",
@@ -5065,7 +5072,7 @@ export const fr: MessageDict = {
"brand.uploadLogo": "Téléverser le logo",
"brand.clear": "Effacer",
"brand.field.logoUrl": "URL du logo",
"brand.placeholder.logoUrl": "https://… ou téléversé /api/brand/logo/files/…",
"brand.placeholder.logoUrl": "https://",
"brand.logoAlt": "Aperçu du logo de marque",
"brand.tipsTitle": "Conseils formule / aperçu",
"brand.saving": "Enregistrement…",
@@ -5226,6 +5233,8 @@ export const fr: MessageDict = {
"pricing.section.publicBefore": "Offres publiques : Free, Starter, Growth, Business et Enterprise. Créez un compte Free, puis passez à un plan supérieur sous",
"pricing.section.publicOr": "ou",
"pricing.section.publicAfter": "via Stripe Checkout. Enterprise reste géré par les ventes.",
"pricing.section.guestPublicBefore": "Offres publiques: Free, Starter, Plus, Growth, Business, Scale et Enterprise. Creez un compte Free, puis",
"pricing.section.guestPublicAfter": "pour passer a un plan superieur via Stripe Checkout. Enterprise reste gere par les ventes.",
"pricing.section.plansLink": "Offres",
"pricing.section.billingLink": "Facturation",
"pricing.section.capabilitiesTitle": "Ce pour quoi chaque offre est conçue",
@@ -5291,7 +5300,7 @@ export const fr: MessageDict = {
"pricing.calc.enterpriseTitle": "Sounds like Enterprise",
"pricing.calc.enterpriseBody": "Your feeds or catalog size exceed self-serve caps. Talk to sales for unlimited capacity and a managed AI grant or BYOK.",
"pricing.calc.ctaPlan": "Choose {name}",
"pricing.calc.disclaimer": "Estimates use list prices and ~2 credits per AI product enhance. Checkout prices come from Stripe. A1 and other client deals stay admin-only.",
"pricing.calc.disclaimer": "Estimates use list prices and ~2 credits per AI product enhance. Checkout prices come from Stripe. Custom client deals stay admin-only.",
"pricing.card.mostPopular": "Le plus populaire",
"pricing.card.custom": "Sur mesure",
"pricing.card.forever": "pour toujours",
+31 -22
View File
@@ -78,7 +78,7 @@ export const it: MessageDict = {
"auth.login.failed": "Accesso non riuscito",
"auth.login.passwordNotSet": "Questo account richiede ancora una password. Apri il link di invito o chiedi a un amministratore di generarne uno nuovo.",
"auth.login.setPasswordFirstTitle": "Imposta prima la password:",
"auth.login.setPasswordFirstBody": "usa il link di invito dalla tua email. Se il link è andato a un indirizzo vecchio (deriva email), chiedi a un amministratore dell'azienda di riemettere un invito per impostare la password a {email}.",
"auth.login.setPasswordFirstBody": "usa il link di invito dalla tua email. Se ti serve un nuovo link, chiedi a un amministratore dell'azienda di riemettere un link per impostare la password a {email}.",
"auth.login.yourEmail": "la tua email",
"auth.login.platformAdminsReissue": "Gli amministratori della piattaforma possono riemettere da",
"auth.login.adminUsersLink": "Admin → Utenti",
@@ -144,9 +144,9 @@ export const it: MessageDict = {
"auth.invite.acceptFailed": "Impossibile accettare l'invito",
"auth.invite.setPasswordFailed": "Impossibile impostare la password",
"auth.invite.expired": "Questo invito non è valido o è scaduto. Chiedi all'amministratore dell'azienda di inviare un nuovo invito, poi apri il nuovo link (o incolla il nuovo token qui sotto).",
"auth.invite.setPasswordExpired": "Questo link per impostare la password non è valido o è scaduto. Chiedi a un amministratore dell'azienda o della piattaforma di riemetterlo, poi apri il nuovo link (o incolla il nuovo token qui sotto).",
"auth.invite.setPasswordExpired": "Questo link per impostare la password non è valido o è scaduto. Chiedi a un amministratore dell'azienda di riemettere un link per impostare la password a questa email, poi apri il nuovo link (o incolla il nuovo token qui sotto).",
"auth.invite.emailMismatchDefault": "Hai effettuato l'accesso con un'email diversa da questo invito.",
"auth.invite.expiredFooter": "Link scaduto? Chiedi a un amministratore di riemetterlo — non c'è un'API di reinvio self-service. Amministratori della piattaforma:",
"auth.invite.expiredFooter": "Link scaduto? Chiedi a un amministratore dell'azienda di riemetterlo non c'è un'API di reinvio self-service.",
"auth.invite.adminUsersLink": "Admin → Utenti",
"auth.invite.doneTitle": "Fai parte del team",
"auth.invite.doneSetPasswordTitle": "Password salvata",
@@ -163,7 +163,7 @@ export const it: MessageDict = {
"auth.invite.switchAccountTitle": "Cambia account:",
"auth.invite.switchAccountBody": "esci, poi completa questo modulo con l'email invitata{emailSuffix}.",
"auth.invite.reissueTitle": "Percorso di riemissione:",
"auth.invite.reissueBody": "se la tua email di accesso reale è cambiata (deriva email), chiedi a un amministratore dell'azienda di revocare questo invito e inviarne uno nuovo all'email con cui accedi. Gli amministratori della piattaforma possono anche riemettere link per impostare la password da Admin → Utenti.",
"auth.invite.reissueBody": "chiedi a un amministratore dell'azienda di riemettere un link per impostare la password a questa email, oppure di revocare questo invito e inviarne uno nuovo all'email con cui accedi.",
"locale.switcher": "Lingua dell'interfaccia",
"locale.menu": "Scegli la lingua dell'interfaccia",
"locale.label": "Lingua della dashboard",
@@ -276,7 +276,7 @@ export const it: MessageDict = {
"settings.table.lastUsed": "Ultimo utilizzo",
"dashboard.demoSandbox": "Sandbox demo",
"dashboard.demoEmptyTitle": "La sandbox demo è vuota",
"dashboard.demoEmptyHint": "La sandbox demo è vuota — passa ad A1 o collega un feed per vedere statistiche reali del catalogo.",
"dashboard.demoEmptyHint": "La sandbox demo è vuota collega un feed per vedere statistiche reali del catalogo.",
"dashboard.overviewTitle": "Panoramica",
"dashboard.quickLinks": "Collegamenti rapidi",
"dashboard.whatsNew": "Novità",
@@ -319,7 +319,7 @@ export const it: MessageDict = {
"dashboard.workflow.exportMap": "Mappa poi esporta",
"dashboard.workflowHint": "Catalogo in ingresso → arricchisci / elabora → esporta o invia agli store. Vai al passo successivo per {name}.",
"dashboard.overviewHint": "Totali in tempo reale per {name}",
"dashboard.demoEmptyMessage": "Passa ad A1 (o un'altra azienda con dati) nell'intestazione, oppure collega un feed qui per popolare questa sandbox.",
"dashboard.demoEmptyMessage": "Collega un feed qui per popolare questa sandbox, oppure passa a un'altra azienda nell'intestazione.",
"dashboard.emptyTitle": "Importa un catalogo",
"dashboard.emptyMessage": "Catalogo in ingresso → arricchisci / elabora → esporta o invia agli store. Inizia con Per iniziare qui sotto (o collega un feed / carica un CSV).",
"dashboard.connectFeedAnyway": "Collega comunque un feed",
@@ -367,29 +367,29 @@ export const it: MessageDict = {
"activation.focus.sync": "Avanti: sincronizza un campione dopo aver salvato la mappatura. Usa Sync su un feed mappato — evita fonti non mappate.",
"activation.focus.dismiss": "Chiudi suggerimento",
"activation.dismiss": "Nascondi checklist",
"hypercare.report.title": "Hypercare di cutover",
"hypercare.report.title": "Serve aiuto dopo il trasferimento?",
"hypercare.report.message": "Dati mancanti o errati dopo la migrazione? Segnalalo così possiamo correggere il tuo workspace.",
"hypercare.report.cta": "Segnala dati mancanti o errati",
"hypercare.report.dismiss": "Ignora",
"hypercare.report.adminTriage": "Apri coda hypercare",
"etl.gaps.title": "What did not migrate",
"etl.gaps.message": "These areas were intentionally left empty or partial after the Descrybe platform cutover. Nothing is “broken” — recreate or reconnect as needed.",
"etl.gaps.title": "What still needs setup",
"etl.gaps.message": "Some items weren't copied when we moved your workspace. Nothing is broken — recreate or reconnect as needed.",
"etl.gaps.dismiss": "Dismiss",
"etl.gaps.apiKeys.title": "API keys",
"etl.gaps.apiKeys.body": "Legacy API key secrets were not migrated. Create a new key to restore API access — the secret is shown only once.",
"etl.gaps.apiKeys.body": "Previous API keys weren't copied. Create a new key to restore API access the secret is shown only once.",
"etl.gaps.apiKeys.cta": "Open API keys",
"etl.gaps.blobs.title": "File contents (blobs)",
"etl.gaps.blobs.body": "Upload records may exist as metadata only. File bytes were not copied — re-upload CSVs or resync object storage if you need the originals.",
"etl.gaps.blobs.countHint": "{count} file records are metadata-only (bytes were not migrated).",
"etl.gaps.blobs.title": "Uploaded files",
"etl.gaps.blobs.body": "File names may appear even when the file itself wasn't copied. Re-upload CSVs if you need the originals.",
"etl.gaps.blobs.countHint": "{count} files are listed without their contents (they weren't copied).",
"etl.gaps.blobs.cta": "Open Files",
"etl.gaps.jobs.title": "Processing history",
"etl.gaps.jobs.body": "Legacy job and task history was not backfilled for cutover. New jobs you start here will appear normally.",
"etl.gaps.jobs.emptyHint": "Optional jobs domain was not run — empty history is expected.",
"etl.gaps.jobs.migratedHint": "{count} jobs were tagged migrated from an optional backfill.",
"etl.gaps.jobs.body": "Earlier job history wasn't imported. New jobs you start here will appear normally.",
"etl.gaps.jobs.emptyHint": "Processing history wasn't imported — an empty list is expected.",
"etl.gaps.jobs.migratedHint": "{count} jobs were imported from the previous workspace.",
"etl.gaps.jobs.cta": "Open Processing",
"etl.gaps.woo.title": "Store credentials",
"etl.gaps.woo.body": "WooCommerce and Shopify secrets may need to be re-entered after cutover — that is a reconnect step, not a broken sync.",
"etl.gaps.woo.body": "WooCommerce and Shopify passwords may need to be re-entered after the move — reconnect your store; sync is not broken.",
"etl.gaps.woo.cta": "Open Stores",
"etl.gaps.settings.title": "Company settings",
"etl.gaps.settings.body": "Only language and merge-products settings were imported. Re-check other company preferences in Settings.",
@@ -456,9 +456,9 @@ export const it: MessageDict = {
"errors.readOnly": "Il sistema è in modalità sola lettura. Le modifiche sono temporaneamente disabilitate.",
"errors.companyAdminDenied": "Solo gli amministratori dell'azienda possono {action}. Chiedi aiuto a un amministratore.",
"errors.companyAdminDenied.actionDefault": "fare questo",
"docs.tryIt.memberCannotCreate": "Solo gli admin azienda possono creare chiavi API. Le chiavi legacy non sono state migrate — incolla una chiave sotto o chiedi a un admin di crearne una nuova.",
"docs.tryIt.noKeysLegacyAdmin": "Le chiavi API legacy non sono state migrate. Creane una con Use my API key o Impostazioni → Chiavi API — il secret è mostrato una sola volta.",
"docs.tryIt.noKeysLegacyMember": "Nessuna chiave API elencata. Le chiavi legacy non sono state migrate — chiedi a un admin di crearne una, o incolla una chiave dk_.",
"docs.tryIt.memberCannotCreate": "Solo gli admin azienda possono creare chiavi API. Le chiavi non sono state migrate — incolla una chiave sotto o chiedi a un admin di crearne una nuova.",
"docs.tryIt.noKeysLegacyAdmin": "Le chiavi API non sono state migrate. Creane una con Use my API key o Impostazioni → Chiavi API — il secret è mostrato una sola volta.",
"docs.tryIt.noKeysLegacyMember": "Nessuna chiave API elencata. Le chiavi non sono state migrate — chiedi a un admin di crearne una, o incolla una chiave dk_.",
"errors.couldNotUpdateLanguage": "Impossibile aggiornare la lingua",
"toast.support.replyTitle": "Risposta del supporto",
"toast.support.replyBody": "Lo staff ha risposto al tuo ticket di supporto.",
@@ -3690,6 +3690,7 @@ export const it: MessageDict = {
"processing.receipt.cancelledDesc": "Il processo appena avviato è stato annullato.",
"processing.receipt.cancelFailed": "Impossibile annullare il processo",
"unsubscribe.title": "Annulla iscrizione",
"unsubscribe.useEmailLink": "Usa il link di disiscrizione nell'email che ti abbiamo inviato. Aprire questa pagina senza quel link non puo modificare le preferenze email.",
"unsubscribe.me": "Annulla la mia iscrizione",
"unsubscribe.invalidLink": "Link non valido",
"unsubscribe.failedShort": "Annullamento iscrizione non riuscito",
@@ -4727,6 +4728,10 @@ export const it: MessageDict = {
"seo.terms.description": "Termini di utilizzo di Descrybe: importazione di feed, arricchimento del catalogo, contenuti assistiti da IA, esportazioni e sincronizzazione WooCommerce per la tua azienda.",
"seo.features.title": "Funzionalità — Feed, arricchimento ed esportazione | Descrybe",
"seo.features.description": "Scopri come Descrybe importa i feed dei fornitori, mappa i campi alla tua tassonomia, arricchisce i dati di prodotto e distribuisce i cataloghi tramite feed di esportazione, WooCommerce o API.",
"seo.contactSales.title": "Contatta le vendite | Descrybe",
"seo.contactSales.description": "Parlaci del tuo catalogo e delle esigenze di capacita: prepareremo un piano personalizzato che puoi pagare online.",
"seo.unsubscribe.title": "Annulla iscrizione | Descrybe",
"seo.unsubscribe.description": "Interrompi le email di marketing Descrybe usando il link di disiscrizione nel messaggio che ti abbiamo inviato.",
"pricing.page.eyebrow": "Prezzi",
"pricing.page.title": "Piani semplici per cataloghi in crescita",
"pricing.page.lead": "Inizia gratis con mappatura feed e pulizia di base. Passa a un piano superiore quando ti servono titoli e descrizioni IA, più prodotti, feed di esportazione o sync WooCommerce — da Starter a Enterprise.",
@@ -4734,6 +4739,8 @@ export const it: MessageDict = {
"pricing.page.subscribedMid": "o confronta i piani in",
"pricing.page.plansLink": "Piani",
"pricing.page.subscribedAfter": ".",
"pricing.page.guestSubscribedBefore": "Gia abbonato?",
"pricing.page.guestSubscribedAfter": "per gestire la fatturazione e confrontare i piani.",
"legal.lastUpdated": "Ultimo aggiornamento: {date}",
"legal.backHome": "← Torna alla home",
"legal.emailLabel": "Email:",
@@ -5065,7 +5072,7 @@ export const it: MessageDict = {
"brand.uploadLogo": "Carica logo",
"brand.clear": "Cancella",
"brand.field.logoUrl": "URL del logo",
"brand.placeholder.logoUrl": "https://… oppure caricato /api/brand/logo/files/…",
"brand.placeholder.logoUrl": "https://",
"brand.logoAlt": "Anteprima logo del brand",
"brand.tipsTitle": "Suggerimenti formula / anteprima",
"brand.saving": "Salvataggio…",
@@ -5226,6 +5233,8 @@ export const it: MessageDict = {
"pricing.section.publicBefore": "Piani pubblici: Free, Starter, Growth, Business ed Enterprise. Crea un account Free, poi passa a un piano superiore in",
"pricing.section.publicOr": "o",
"pricing.section.publicAfter": "tramite Stripe Checkout. Enterprise resta gestito dalle vendite.",
"pricing.section.guestPublicBefore": "Piani pubblici: Free, Starter, Plus, Growth, Business, Scale ed Enterprise. Crea un account Free, poi",
"pricing.section.guestPublicAfter": "per passare a un piano superiore tramite Stripe Checkout. Enterprise resta gestito dalle vendite.",
"pricing.section.plansLink": "Piani",
"pricing.section.billingLink": "Fatturazione",
"pricing.section.capabilitiesTitle": "A cosa è pensato ogni piano",
@@ -5291,7 +5300,7 @@ export const it: MessageDict = {
"pricing.calc.enterpriseTitle": "Sounds like Enterprise",
"pricing.calc.enterpriseBody": "Your feeds or catalog size exceed self-serve caps. Talk to sales for unlimited capacity and a managed AI grant or BYOK.",
"pricing.calc.ctaPlan": "Choose {name}",
"pricing.calc.disclaimer": "Estimates use list prices and ~2 credits per AI product enhance. Checkout prices come from Stripe. A1 and other client deals stay admin-only.",
"pricing.calc.disclaimer": "Estimates use list prices and ~2 credits per AI product enhance. Checkout prices come from Stripe. Custom client deals stay admin-only.",
"pricing.card.mostPopular": "Più popolare",
"pricing.card.custom": "Personalizzato",
"pricing.card.forever": "per sempre",
+28 -19
View File
@@ -78,7 +78,7 @@ export const ja: MessageDict = {
"auth.login.failed": "ログインに失敗しました",
"auth.login.passwordNotSet": "このアカウントにはまだパスワードが必要です。招待リンクを開くか、管理者に再発行を依頼してください。",
"auth.login.setPasswordFirstTitle": "先にパスワードを設定:",
"auth.login.setPasswordFirstBody": "メールの招待リンクを使用してください。リンクが古いアドレスに送られた場合(メール変更)、会社の管理者に {email} 向けのパスワード設定招待の再発行を依頼してください。",
"auth.login.setPasswordFirstBody": "メールの招待リンクを使用してください。新しいリンクが必要な場合は、会社の管理者に {email} 向けのパスワード設定リンクの再発行を依頼してください。",
"auth.login.yourEmail": "あなたのメール",
"auth.login.platformAdminsReissue": "プラットフォーム管理者は次から再発行できます:",
"auth.login.adminUsersLink": "管理 → ユーザー",
@@ -144,9 +144,9 @@ export const ja: MessageDict = {
"auth.invite.acceptFailed": "招待を承認できませんでした",
"auth.invite.setPasswordFailed": "パスワードを設定できませんでした",
"auth.invite.expired": "この招待は無効または期限切れです。会社の管理者に新しい招待の送信を依頼し、新しいリンクを開くか(下に新しいトークンを貼り付けてください)。",
"auth.invite.setPasswordExpired": "このパスワード設定リンクは無効または期限切れです。会社またはプラットフォームの管理者に再発行を依頼し、新しいリンクを開くか(下に新しいトークンを貼り付けてください)。",
"auth.invite.setPasswordExpired": "このパスワード設定リンクは無効または期限切れです。会社の管理者にこのメール向けのパスワード設定リンクの再発行を依頼し、新しいリンクを開くか(下に新しいトークンを貼り付けてください)。",
"auth.invite.emailMismatchDefault": "この招待とは別のメールアドレスでログインしています。",
"auth.invite.expiredFooter": "期限切れのリンクですか?管理者に再発行を依頼してください — セルフサービスの再送信APIはありません。プラットフォーム管理者:",
"auth.invite.expiredFooter": "期限切れのリンクですか?会社の管理者に再発行を依頼してください — セルフサービスの再送信APIはありません。",
"auth.invite.adminUsersLink": "管理 → ユーザー",
"auth.invite.doneTitle": "チームに参加しました",
"auth.invite.doneSetPasswordTitle": "パスワードを保存しました",
@@ -163,7 +163,7 @@ export const ja: MessageDict = {
"auth.invite.switchAccountTitle": "アカウント切替:",
"auth.invite.switchAccountBody": "ログアウトしてから、招待されたメール{emailSuffix}でこのフォームを完了してください。",
"auth.invite.reissueTitle": "再発行の手順:",
"auth.invite.reissueBody": "実際のログイン用メールが変わった場合(メール変更)、会社の管理者にこの招待の取り消しと、ログインに使うメールへの新しい招待送信を依頼してください。プラットフォーム管理者は「管理 → ユーザー」からパスワード設定リンクも再発行できます。",
"auth.invite.reissueBody": "会社の管理者にこのメール向けのパスワード設定リンクの再発行を依頼するか、この招待を取り消してログインに使うメールへ新しい招待を送るよう依頼してください。",
"locale.switcher": "インターフェース言語",
"locale.menu": "インターフェース言語を選択",
"locale.label": "ダッシュボードの言語",
@@ -276,7 +276,7 @@ export const ja: MessageDict = {
"settings.table.lastUsed": "最終使用",
"dashboard.demoSandbox": "デモサンドボックス",
"dashboard.demoEmptyTitle": "デモサンドボックスは空です",
"dashboard.demoEmptyHint": "デモサンドボックスは空です — A1に切り替えるかフィードを接続して実際のカタログ統計を表示します。",
"dashboard.demoEmptyHint": "デモサンドボックスは空です — フィードを接続して実際のカタログ統計を表示します。",
"dashboard.overviewTitle": "概要",
"dashboard.quickLinks": "クイックリンク",
"dashboard.whatsNew": "新着情報",
@@ -319,7 +319,7 @@ export const ja: MessageDict = {
"dashboard.workflow.exportMap": "マップしてエクスポート",
"dashboard.workflowHint": "カタログ取込 → 充実 / 処理 → エクスポートまたはストアへプッシュ。{name} の次のステップへ。",
"dashboard.overviewHint": "{name} のリアルタイム合計",
"dashboard.demoEmptyMessage": "ヘッダーでA1(または別のシード済み会社)に切り替えるか、ここでフィードを接続してサンドボックスにデータを入れます。",
"dashboard.demoEmptyMessage": "ここでフィードを接続してサンドボックスにデータを入れるか、ヘッダーで別の会社に切り替えます。",
"dashboard.emptyTitle": "カタログを取り込む",
"dashboard.emptyMessage": "カタログ取込 → 充実 / 処理 → エクスポートまたはストアへプッシュ。下の「はじめに」から開始(またはフィード接続 / CSVアップロード)。",
"dashboard.connectFeedAnyway": "それでもフィードを接続",
@@ -367,29 +367,29 @@ export const ja: MessageDict = {
"activation.focus.sync": "次へ: マッピング保存後にサンプルを同期します。マップ済みフィードで Sync を使い、未マップのソースは避けてください。",
"activation.focus.dismiss": "ヒントを閉じる",
"activation.dismiss": "チェックリストを閉じる",
"hypercare.report.title": "カットオーバー hypercare",
"hypercare.report.title": "移行後のサポートが必要ですか?",
"hypercare.report.message": "移行後にデータの欠落や誤りがありますか?報告いただければワークスペースを修正します。",
"hypercare.report.cta": "欠落・誤ったデータを報告",
"hypercare.report.dismiss": "閉じる",
"hypercare.report.adminTriage": "hypercareキューを開く",
"etl.gaps.title": "What did not migrate",
"etl.gaps.message": "These areas were intentionally left empty or partial after the Descrybe platform cutover. Nothing is “broken” — recreate or reconnect as needed.",
"etl.gaps.title": "What still needs setup",
"etl.gaps.message": "Some items weren't copied when we moved your workspace. Nothing is broken — recreate or reconnect as needed.",
"etl.gaps.dismiss": "Dismiss",
"etl.gaps.apiKeys.title": "API keys",
"etl.gaps.apiKeys.body": "Legacy API key secrets were not migrated. Create a new key to restore API access — the secret is shown only once.",
"etl.gaps.apiKeys.body": "Previous API keys weren't copied. Create a new key to restore API access the secret is shown only once.",
"etl.gaps.apiKeys.cta": "Open API keys",
"etl.gaps.blobs.title": "File contents (blobs)",
"etl.gaps.blobs.body": "Upload records may exist as metadata only. File bytes were not copied — re-upload CSVs or resync object storage if you need the originals.",
"etl.gaps.blobs.countHint": "{count} file records are metadata-only (bytes were not migrated).",
"etl.gaps.blobs.title": "Uploaded files",
"etl.gaps.blobs.body": "File names may appear even when the file itself wasn't copied. Re-upload CSVs if you need the originals.",
"etl.gaps.blobs.countHint": "{count} files are listed without their contents (they weren't copied).",
"etl.gaps.blobs.cta": "Open Files",
"etl.gaps.jobs.title": "Processing history",
"etl.gaps.jobs.body": "Legacy job and task history was not backfilled for cutover. New jobs you start here will appear normally.",
"etl.gaps.jobs.emptyHint": "Optional jobs domain was not run — empty history is expected.",
"etl.gaps.jobs.migratedHint": "{count} jobs were tagged migrated from an optional backfill.",
"etl.gaps.jobs.body": "Earlier job history wasn't imported. New jobs you start here will appear normally.",
"etl.gaps.jobs.emptyHint": "Processing history wasn't imported — an empty list is expected.",
"etl.gaps.jobs.migratedHint": "{count} jobs were imported from the previous workspace.",
"etl.gaps.jobs.cta": "Open Processing",
"etl.gaps.woo.title": "Store credentials",
"etl.gaps.woo.body": "WooCommerce and Shopify secrets may need to be re-entered after cutover — that is a reconnect step, not a broken sync.",
"etl.gaps.woo.body": "WooCommerce and Shopify passwords may need to be re-entered after the move — reconnect your store; sync is not broken.",
"etl.gaps.woo.cta": "Open Stores",
"etl.gaps.settings.title": "Company settings",
"etl.gaps.settings.body": "Only language and merge-products settings were imported. Re-check other company preferences in Settings.",
@@ -3690,6 +3690,7 @@ export const ja: MessageDict = {
"processing.receipt.cancelledDesc": "開始直後のジョブがキャンセルされました。",
"processing.receipt.cancelFailed": "処理ジョブをキャンセルできませんでした",
"unsubscribe.title": "配信停止",
"unsubscribe.useEmailLink": "Use the unsubscribe link in the email we sent you. Opening this page without that link cannot change your email preferences.",
"unsubscribe.me": "配信を停止する",
"unsubscribe.invalidLink": "無効なリンク",
"unsubscribe.failedShort": "配信停止に失敗しました",
@@ -4727,6 +4728,10 @@ export const ja: MessageDict = {
"seo.terms.description": "Descrybe の利用条件:フィードのインポート、カタログのエンリッチメント、AI 支援コンテンツ、エクスポート、およびビジネス向け WooCommerce 同期。",
"seo.features.title": "機能 — フィード、エンリッチメント、エクスポート | Descrybe",
"seo.features.description": "Descrybe がサプライヤーフィードを取り込み、フィールドをタクソノミーにマッピングし、商品データをエンリッチし、エクスポートフィード、WooCommerce、または API でカタログを配信する仕組みをご確認ください。",
"seo.contactSales.title": "Contact sales | Descrybe",
"seo.contactSales.description": "Tell us about your catalog and capacity needs — we will prepare a custom plan you can pay online.",
"seo.unsubscribe.title": "Unsubscribe | Descrybe",
"seo.unsubscribe.description": "Stop marketing emails from Descrybe using the unsubscribe link in the message we sent you.",
"pricing.page.eyebrow": "料金",
"pricing.page.title": "成長するカタログ向けのシンプルなプラン",
"pricing.page.lead": "フィードのマッピングと基本的な整形から無料で始められます。AI によるタイトル/説明、商品数の増加、エクスポートフィード、WooCommerce 連携が必要になったらアップグレード — Starter から Enterprise まで。",
@@ -4734,6 +4739,8 @@ export const ja: MessageDict = {
"pricing.page.subscribedMid": "、プラン比較は",
"pricing.page.plansLink": "プラン",
"pricing.page.subscribedAfter": "へ。",
"pricing.page.guestSubscribedBefore": "Already subscribed?",
"pricing.page.guestSubscribedAfter": "to manage billing and compare plans.",
"legal.lastUpdated": "最終更新日: {date}",
"legal.backHome": "← ホームに戻る",
"legal.emailLabel": "メール:",
@@ -5065,7 +5072,7 @@ export const ja: MessageDict = {
"brand.uploadLogo": "ロゴをアップロード",
"brand.clear": "クリア",
"brand.field.logoUrl": "ロゴ URL",
"brand.placeholder.logoUrl": "https://… またはアップロード済み /api/brand/logo/files/…",
"brand.placeholder.logoUrl": "https://",
"brand.logoAlt": "ブランドロゴのプレビュー",
"brand.tipsTitle": "数式 / プレビューのヒント",
"brand.saving": "保存中…",
@@ -5226,6 +5233,8 @@ export const ja: MessageDict = {
"pricing.section.publicBefore": "公開プラン:Free、Starter、Growth、Business、Enterprise。Freeアカウントを作成し、その後",
"pricing.section.publicOr": "または",
"pricing.section.publicAfter": "でStripe Checkout経由でアップグレード。Enterpriseはセールス主導のままです。",
"pricing.section.guestPublicBefore": "Public plans: Free, Starter, Plus, Growth, Business, Scale, and Enterprise. Create a Free account, then",
"pricing.section.guestPublicAfter": "to upgrade via Stripe Checkout. Enterprise remains sales-led.",
"pricing.section.plansLink": "プラン",
"pricing.section.billingLink": "請求",
"pricing.section.capabilitiesTitle": "各プランが想定する用途",
@@ -5291,7 +5300,7 @@ export const ja: MessageDict = {
"pricing.calc.enterpriseTitle": "Sounds like Enterprise",
"pricing.calc.enterpriseBody": "Your feeds or catalog size exceed self-serve caps. Talk to sales for unlimited capacity and a managed AI grant or BYOK.",
"pricing.calc.ctaPlan": "Choose {name}",
"pricing.calc.disclaimer": "Estimates use list prices and ~2 credits per AI product enhance. Checkout prices come from Stripe. A1 and other client deals stay admin-only.",
"pricing.calc.disclaimer": "Estimates use list prices and ~2 credits per AI product enhance. Checkout prices come from Stripe. Custom client deals stay admin-only.",
"pricing.card.mostPopular": "一番人気",
"pricing.card.custom": "カスタム",
"pricing.card.forever": "ずっと",
+31 -22
View File
@@ -78,7 +78,7 @@ export const nl: MessageDict = {
"auth.login.failed": "Inloggen mislukt",
"auth.login.passwordNotSet": "Dit account heeft nog een wachtwoord nodig. Open uw uitnodigingslink of vraag een beheerder om een nieuwe.",
"auth.login.setPasswordFirstTitle": "Stel eerst een wachtwoord in:",
"auth.login.setPasswordFirstBody": "gebruik de uitnodigingslink uit uw e-mail. Als de link naar een oud adres ging (e-maildrift), vraag dan een bedrijfsbeheerder om een nieuwe set-wachtwoorduitnodiging naar {email} te sturen.",
"auth.login.setPasswordFirstBody": "gebruik de uitnodigingslink uit uw e-mail. Als u een nieuwe link nodig hebt, vraag dan een bedrijfsbeheerder om een set-wachtwoordlink opnieuw uit te geven naar {email}.",
"auth.login.yourEmail": "uw e-mail",
"auth.login.platformAdminsReissue": "Platformbeheerders kunnen opnieuw uitgeven via",
"auth.login.adminUsersLink": "Admin → Gebruikers",
@@ -144,9 +144,9 @@ export const nl: MessageDict = {
"auth.invite.acceptFailed": "Uitnodiging kon niet worden geaccepteerd",
"auth.invite.setPasswordFailed": "Wachtwoord kon niet worden ingesteld",
"auth.invite.expired": "Deze uitnodiging is ongeldig of verlopen. Vraag uw bedrijfsbeheerder om een nieuwe uitnodiging te sturen en open de nieuwe link (of plak het nieuwe token hieronder).",
"auth.invite.setPasswordExpired": "Deze set-wachtwoordlink is ongeldig of verlopen. Vraag een bedrijfs- of platformbeheerder om hem opnieuw uit te geven en open de nieuwe link (of plak het nieuwe token hieronder).",
"auth.invite.setPasswordExpired": "Deze set-wachtwoordlink is ongeldig of verlopen. Vraag een bedrijfsbeheerder om een set-wachtwoordlink opnieuw uit te geven naar dit e-mailadres en open de nieuwe link (of plak het nieuwe token hieronder).",
"auth.invite.emailMismatchDefault": "U bent ingelogd met een ander e-mailadres dan deze uitnodiging.",
"auth.invite.expiredFooter": "Verlopen link? Vraag een beheerder om opnieuw uit te geven — er is geen self-service-API voor opnieuw verzenden. Platformbeheerders:",
"auth.invite.expiredFooter": "Verlopen link? Vraag een bedrijfsbeheerder om opnieuw uit te geven er is geen self-service-API voor opnieuw verzenden.",
"auth.invite.adminUsersLink": "Admin → Gebruikers",
"auth.invite.doneTitle": "U bent in het team",
"auth.invite.doneSetPasswordTitle": "Wachtwoord opgeslagen",
@@ -163,7 +163,7 @@ export const nl: MessageDict = {
"auth.invite.switchAccountTitle": "Account wisselen:",
"auth.invite.switchAccountBody": "log uit en voltooi dit formulier met het uitgenodigde e-mailadres{emailSuffix}.",
"auth.invite.reissueTitle": "Pad voor opnieuw uitgeven:",
"auth.invite.reissueBody": "als uw echte login-e-mail is gewijzigd (e-maildrift), vraag dan een bedrijfsbeheerder om deze uitnodiging in te trekken en een nieuwe te sturen naar het e-mailadres waarmee u inlogt. Platformbeheerders kunnen ook set-wachtwoordlinks opnieuw uitgeven via Admin → Gebruikers.",
"auth.invite.reissueBody": "vraag een bedrijfsbeheerder om een set-wachtwoordlink opnieuw uit te geven naar dit e-mailadres, of om deze uitnodiging in te trekken en een nieuwe te sturen naar het e-mailadres waarmee u inlogt.",
"locale.switcher": "Interfacetaal",
"locale.menu": "Kies interfacetaal",
"locale.label": "Dashboardtaal",
@@ -276,7 +276,7 @@ export const nl: MessageDict = {
"settings.table.lastUsed": "Laatst gebruikt",
"dashboard.demoSandbox": "Demo-sandbox",
"dashboard.demoEmptyTitle": "Demo-sandbox is leeg",
"dashboard.demoEmptyHint": "Demo-sandbox is leeg — schakel over naar A1 of koppel een feed om echte catalogusstatistieken te zien.",
"dashboard.demoEmptyHint": "Demo-sandbox is leeg koppel een feed om echte catalogusstatistieken te zien.",
"dashboard.overviewTitle": "Overzicht",
"dashboard.quickLinks": "Snelle links",
"dashboard.whatsNew": "Wat is nieuw",
@@ -319,7 +319,7 @@ export const nl: MessageDict = {
"dashboard.workflow.exportMap": "Mappen en exporteren",
"dashboard.workflowHint": "Catalogus in → verrijken / verwerken → exporteren of pushen naar winkels. Ga naar de volgende stap voor {name}.",
"dashboard.overviewHint": "Live totalen voor {name}",
"dashboard.demoEmptyMessage": "Schakel in de header over naar A1 (of een ander geseeded bedrijf), of koppel hier een feed om deze sandbox te vullen.",
"dashboard.demoEmptyMessage": "Koppel hier een feed om deze sandbox te vullen, of schakel in de header over naar een ander bedrijf.",
"dashboard.emptyTitle": "Haal een catalogus binnen",
"dashboard.emptyMessage": "Catalogus in → verrijken / verwerken → exporteren of pushen naar winkels. Begin met Aan de slag hieronder (of koppel een feed / upload een CSV).",
"dashboard.connectFeedAnyway": "Feed toch koppelen",
@@ -367,29 +367,29 @@ export const nl: MessageDict = {
"activation.focus.sync": "Volgende: synchroniseer een steekproef na het opslaan van de mapping. Gebruik Sync op een gemapte feed — vermijd ongemapte bronnen.",
"activation.focus.dismiss": "Hint sluiten",
"activation.dismiss": "Checklist verbergen",
"hypercare.report.title": "Cutover-hypercare",
"hypercare.report.title": "Hulp nodig na de verhuizing?",
"hypercare.report.message": "Ontbrekende of verkeerde data na migratie? Meld het zodat we je workspace kunnen herstellen.",
"hypercare.report.cta": "Ontbrekende of verkeerde data melden",
"hypercare.report.dismiss": "Sluiten",
"hypercare.report.adminTriage": "Hypercare-wachtrij openen",
"etl.gaps.title": "What did not migrate",
"etl.gaps.message": "These areas were intentionally left empty or partial after the Descrybe platform cutover. Nothing is “broken” — recreate or reconnect as needed.",
"etl.gaps.title": "What still needs setup",
"etl.gaps.message": "Some items weren't copied when we moved your workspace. Nothing is broken — recreate or reconnect as needed.",
"etl.gaps.dismiss": "Dismiss",
"etl.gaps.apiKeys.title": "API keys",
"etl.gaps.apiKeys.body": "Legacy API key secrets were not migrated. Create a new key to restore API access — the secret is shown only once.",
"etl.gaps.apiKeys.body": "Previous API keys weren't copied. Create a new key to restore API access the secret is shown only once.",
"etl.gaps.apiKeys.cta": "Open API keys",
"etl.gaps.blobs.title": "File contents (blobs)",
"etl.gaps.blobs.body": "Upload records may exist as metadata only. File bytes were not copied — re-upload CSVs or resync object storage if you need the originals.",
"etl.gaps.blobs.countHint": "{count} file records are metadata-only (bytes were not migrated).",
"etl.gaps.blobs.title": "Uploaded files",
"etl.gaps.blobs.body": "File names may appear even when the file itself wasn't copied. Re-upload CSVs if you need the originals.",
"etl.gaps.blobs.countHint": "{count} files are listed without their contents (they weren't copied).",
"etl.gaps.blobs.cta": "Open Files",
"etl.gaps.jobs.title": "Processing history",
"etl.gaps.jobs.body": "Legacy job and task history was not backfilled for cutover. New jobs you start here will appear normally.",
"etl.gaps.jobs.emptyHint": "Optional jobs domain was not run — empty history is expected.",
"etl.gaps.jobs.migratedHint": "{count} jobs were tagged migrated from an optional backfill.",
"etl.gaps.jobs.body": "Earlier job history wasn't imported. New jobs you start here will appear normally.",
"etl.gaps.jobs.emptyHint": "Processing history wasn't imported — an empty list is expected.",
"etl.gaps.jobs.migratedHint": "{count} jobs were imported from the previous workspace.",
"etl.gaps.jobs.cta": "Open Processing",
"etl.gaps.woo.title": "Store credentials",
"etl.gaps.woo.body": "WooCommerce and Shopify secrets may need to be re-entered after cutover — that is a reconnect step, not a broken sync.",
"etl.gaps.woo.body": "WooCommerce and Shopify passwords may need to be re-entered after the move — reconnect your store; sync is not broken.",
"etl.gaps.woo.cta": "Open Stores",
"etl.gaps.settings.title": "Company settings",
"etl.gaps.settings.body": "Only language and merge-products settings were imported. Re-check other company preferences in Settings.",
@@ -456,9 +456,9 @@ export const nl: MessageDict = {
"errors.readOnly": "Het systeem staat in alleen-lezenmodus. Wijzigingen zijn tijdelijk uitgeschakeld.",
"errors.companyAdminDenied": "Alleen bedrijfsbeheerders kunnen {action}. Vraag een beheerder om hulp.",
"errors.companyAdminDenied.actionDefault": "dit doen",
"docs.tryIt.memberCannotCreate": "Alleen bedrijfsadmins kunnen API-sleutels maken. Legacy-sleutels zijn niet gemigreerd — plak hieronder een sleutel of vraag een admin om een nieuwe.",
"docs.tryIt.noKeysLegacyAdmin": "Legacy API-sleutels zijn niet gemigreerd. Maak een nieuwe via Use my API key of Instellingen → API-sleutels — het geheim wordt slechts één keer getoond.",
"docs.tryIt.noKeysLegacyMember": "Geen API-sleutels vermeld. Legacy-sleutels niet gemigreerd — vraag een admin er een aan te maken, of plak een dk_-sleutel.",
"docs.tryIt.memberCannotCreate": "Alleen bedrijfsadmins kunnen API-sleutels maken. sleutels zijn niet gemigreerd — plak hieronder een sleutel of vraag een admin om een nieuwe.",
"docs.tryIt.noKeysLegacyAdmin": "API-sleutels zijn niet gemigreerd. Maak een nieuwe via Use my API key of Instellingen → API-sleutels — het geheim wordt slechts één keer getoond.",
"docs.tryIt.noKeysLegacyMember": "Geen API-sleutels vermeld. sleutels niet gemigreerd — vraag een admin er een aan te maken, of plak een dk_-sleutel.",
"errors.couldNotUpdateLanguage": "Taal kon niet worden bijgewerkt",
"toast.support.replyTitle": "Supportantwoord",
"toast.support.replyBody": "Medewerkers hebben gereageerd op uw supportticket.",
@@ -3690,6 +3690,7 @@ export const nl: MessageDict = {
"processing.receipt.cancelledDesc": "De zojuist gestarte taak is geannuleerd.",
"processing.receipt.cancelFailed": "Kon verwerkingstaak niet annuleren",
"unsubscribe.title": "Uitschrijven",
"unsubscribe.useEmailLink": "Gebruik de afmeldlink in de e-mail die we u hebben gestuurd. Deze pagina openen zonder die link kan uw e-mailvoorkeuren niet wijzigen.",
"unsubscribe.me": "Mij uitschrijven",
"unsubscribe.invalidLink": "Ongeldige link",
"unsubscribe.failedShort": "Uitschrijven mislukt",
@@ -4727,6 +4728,10 @@ export const nl: MessageDict = {
"seo.terms.description": "Gebruiksvoorwaarden van Descrybe: feed-import, catalogusverrijking, AI-ondersteunde content, exporten en synchronisatie met WooCommerce voor uw bedrijf.",
"seo.features.title": "Functies — Feeds, verrijking en export | Descrybe",
"seo.features.description": "Ontdek hoe Descrybe leveranciersfeeds importeert, velden toewijst aan uw taxonomie, productgegevens verrijkt en catalogi levert via exportfeeds, WooCommerce of API.",
"seo.contactSales.title": "Contact sales | Descrybe",
"seo.contactSales.description": "Vertel ons over uw catalogus en capaciteitsbehoefte: we maken een plan op maat dat u online kunt betalen.",
"seo.unsubscribe.title": "Uitschrijven | Descrybe",
"seo.unsubscribe.description": "Stop marketingmails van Descrybe via de afmeldlink in het bericht dat we u hebben gestuurd.",
"pricing.page.eyebrow": "Prijzen",
"pricing.page.title": "Eenvoudige plannen voor groeiende catalogi",
"pricing.page.lead": "Begin gratis met feed-mapping en basisopschoning. Upgrade wanneer je AI-titels en -beschrijvingen, meer producten, exportfeeds of WooCommerce-sync nodig hebt — van Starter tot Enterprise.",
@@ -4734,6 +4739,8 @@ export const nl: MessageDict = {
"pricing.page.subscribedMid": "of vergelijk plannen in",
"pricing.page.plansLink": "Plannen",
"pricing.page.subscribedAfter": ".",
"pricing.page.guestSubscribedBefore": "Al geabonneerd?",
"pricing.page.guestSubscribedAfter": "om facturatie te beheren en plannen te vergelijken.",
"legal.lastUpdated": "Laatst bijgewerkt: {date}",
"legal.backHome": "← Terug naar home",
"legal.emailLabel": "E-mail:",
@@ -5065,7 +5072,7 @@ export const nl: MessageDict = {
"brand.uploadLogo": "Logo uploaden",
"brand.clear": "Wissen",
"brand.field.logoUrl": "Logo-URL",
"brand.placeholder.logoUrl": "https://… of geüpload /api/brand/logo/files/…",
"brand.placeholder.logoUrl": "https://",
"brand.logoAlt": "Voorbeeld van merklogo",
"brand.tipsTitle": "Formule- / voorbeeldtips",
"brand.saving": "Opslaan…",
@@ -5226,6 +5233,8 @@ export const nl: MessageDict = {
"pricing.section.publicBefore": "Publieke plannen: Free, Starter, Growth, Business en Enterprise. Maak een Free-account aan en upgrade daarna onder",
"pricing.section.publicOr": "of",
"pricing.section.publicAfter": "via Stripe Checkout. Enterprise blijft sales-gestuurd.",
"pricing.section.guestPublicBefore": "Publieke plannen: Free, Starter, Plus, Growth, Business, Scale en Enterprise. Maak een Free-account aan en",
"pricing.section.guestPublicAfter": "om te upgraden via Stripe Checkout. Enterprise blijft sales-gestuurd.",
"pricing.section.plansLink": "Plannen",
"pricing.section.billingLink": "Facturering",
"pricing.section.capabilitiesTitle": "Waarvoor elk plan is gebouwd",
@@ -5291,7 +5300,7 @@ export const nl: MessageDict = {
"pricing.calc.enterpriseTitle": "Sounds like Enterprise",
"pricing.calc.enterpriseBody": "Your feeds or catalog size exceed self-serve caps. Talk to sales for unlimited capacity and a managed AI grant or BYOK.",
"pricing.calc.ctaPlan": "Choose {name}",
"pricing.calc.disclaimer": "Estimates use list prices and ~2 credits per AI product enhance. Checkout prices come from Stripe. A1 and other client deals stay admin-only.",
"pricing.calc.disclaimer": "Estimates use list prices and ~2 credits per AI product enhance. Checkout prices come from Stripe. Custom client deals stay admin-only.",
"pricing.card.mostPopular": "Meest populair",
"pricing.card.custom": "Op maat",
"pricing.card.forever": "voor altijd",
+28 -19
View File
@@ -78,7 +78,7 @@ export const pl: MessageDict = {
"auth.login.failed": "Logowanie nie powiodło się",
"auth.login.passwordNotSet": "To konto nadal wymaga hasła. Otwórz link zaproszenia lub poproś administratora o wystawienie nowego.",
"auth.login.setPasswordFirstTitle": "Najpierw ustaw hasło:",
"auth.login.setPasswordFirstBody": "użyj linku zaproszenia z e-maila. Jeśli link poszedł na stary adres (dryf e-mail), poproś administratora firmy o ponowne wystawienie zaproszenia do ustawienia hasła na {email}.",
"auth.login.setPasswordFirstBody": "użyj linku zaproszenia z e-maila. Jeśli potrzebujesz nowego linku, poproś administratora firmy o ponowne wystawienie linku do ustawienia hasła na {email}.",
"auth.login.yourEmail": "twój e-mail",
"auth.login.platformAdminsReissue": "Administratorzy platformy mogą ponownie wystawić z",
"auth.login.adminUsersLink": "Admin → Użytkownicy",
@@ -144,9 +144,9 @@ export const pl: MessageDict = {
"auth.invite.acceptFailed": "Nie można zaakceptować zaproszenia",
"auth.invite.setPasswordFailed": "Nie można ustawić hasła",
"auth.invite.expired": "To zaproszenie jest nieprawidłowe lub wygasło. Poproś administratora firmy o nowe zaproszenie, a następnie otwórz nowy link (lub wklej nowy token poniżej).",
"auth.invite.setPasswordExpired": "Ten link do ustawienia hasła jest nieprawidłowy lub wygasł. Poproś administratora firmy lub platformy o ponowne wystawienie, a następnie otwórz nowy link (lub wklej nowy token poniżej).",
"auth.invite.setPasswordExpired": "Ten link do ustawienia hasła jest nieprawidłowy lub wygasł. Poproś administratora firmy o ponowne wystawienie linku do ustawienia hasła na ten e-mail, a następnie otwórz nowy link (lub wklej nowy token poniżej).",
"auth.invite.emailMismatchDefault": "Jesteś zalogowany na inny e-mail niż w tym zaproszeniu.",
"auth.invite.expiredFooter": "Wygasły link? Poproś administratora o ponowne wystawienie — nie ma API samodzielnego ponownego wysyłania. Administratorzy platformy:",
"auth.invite.expiredFooter": "Wygasły link? Poproś administratora firmy o ponowne wystawienie nie ma API samodzielnego ponownego wysyłania.",
"auth.invite.adminUsersLink": "Admin → Użytkownicy",
"auth.invite.doneTitle": "JesteÅ› w zespole",
"auth.invite.doneSetPasswordTitle": "Hasło zapisane",
@@ -163,7 +163,7 @@ export const pl: MessageDict = {
"auth.invite.switchAccountTitle": "Zmień konto:",
"auth.invite.switchAccountBody": "wyloguj się, a następnie dokończ ten formularz zaproszonym e-mailem{emailSuffix}.",
"auth.invite.reissueTitle": "Ścieżka ponownego wystawienia:",
"auth.invite.reissueBody": "jeśli zmienił się Twój prawdziwy e-mail logowania (dryf e-mail), poproś administratora firmy o unieważnienie tego zaproszenia i wysłanie nowego na e-mail używany do logowania. Administratorzy platformy mogą też ponownie wystawiać linki ustawienia hasła w Admin → Użytkownicy.",
"auth.invite.reissueBody": "poproś administratora firmy o ponowne wystawienie linku do ustawienia hasła na ten e-mail albo o unieważnienie tego zaproszenia i wysłanie nowego na e-mail używany do logowania.",
"locale.switcher": "Język interfejsu",
"locale.menu": "Wybierz język interfejsu",
"locale.label": "Język panelu",
@@ -276,7 +276,7 @@ export const pl: MessageDict = {
"settings.table.lastUsed": "Ostatnio użyty",
"dashboard.demoSandbox": "Piaskownica demo",
"dashboard.demoEmptyTitle": "Piaskownica demo jest pusta",
"dashboard.demoEmptyHint": "Piaskownica demo jest pusta — przełącz na A1 lub podłącz feed, aby zobaczyć realne statystyki katalogu.",
"dashboard.demoEmptyHint": "Piaskownica demo jest pusta — podłącz feed, aby zobaczyć realne statystyki katalogu.",
"dashboard.overviewTitle": "PrzeglÄ…d",
"dashboard.quickLinks": "Szybkie linki",
"dashboard.whatsNew": "Co nowego",
@@ -319,7 +319,7 @@ export const pl: MessageDict = {
"dashboard.workflow.exportMap": "Mapuj, potem eksportuj",
"dashboard.workflowHint": "Katalog wejściowy → wzbogacenie / przetwarzanie → eksport lub wypchnięcie do sklepów. Przejdź do następnego kroku dla {name}.",
"dashboard.overviewHint": "Bieżące sumy dla {name}",
"dashboard.demoEmptyMessage": "Przełącz na A1 (lub inną firmę z danymi) w nagłówku albo podłącz tu feed, aby wypełnić tę piaskownicę.",
"dashboard.demoEmptyMessage": "Podłącz tu feed, aby wypełnić tę piaskownicę, albo przełącz na inną firmę w nagłówku.",
"dashboard.emptyTitle": "WciÄ…gnij katalog",
"dashboard.emptyMessage": "Katalog wejściowy → wzbogacenie / przetwarzanie → eksport lub wypchnięcie do sklepów. Zacznij od Pierwsze kroki poniżej (lub podłącz feed / prześlij CSV).",
"dashboard.connectFeedAnyway": "Podłącz feed mimo to",
@@ -367,29 +367,29 @@ export const pl: MessageDict = {
"activation.focus.sync": "Dalej: zsynchronizuj próbkę po zapisaniu mapowania. Użyj Sync na zmapowanym feedzie — unikaj niezmapowanych źródeł.",
"activation.focus.dismiss": "Zamknij podpowiedź",
"activation.dismiss": "Ukryj listÄ™",
"hypercare.report.title": "Hypercare cutover",
"hypercare.report.title": "Potrzebujesz pomocy po przeniesieniu?",
"hypercare.report.message": "Brakujące lub błędne dane po migracji? Zgłoś to, abyśmy mogli naprawić Twój workspace.",
"hypercare.report.cta": "Zgłoś brakujące lub błędne dane",
"hypercare.report.dismiss": "Odrzuć",
"hypercare.report.adminTriage": "Otwórz kolejkę hypercare",
"etl.gaps.title": "What did not migrate",
"etl.gaps.message": "These areas were intentionally left empty or partial after the Descrybe platform cutover. Nothing is “broken” — recreate or reconnect as needed.",
"etl.gaps.title": "What still needs setup",
"etl.gaps.message": "Some items weren't copied when we moved your workspace. Nothing is broken — recreate or reconnect as needed.",
"etl.gaps.dismiss": "Dismiss",
"etl.gaps.apiKeys.title": "API keys",
"etl.gaps.apiKeys.body": "Legacy API key secrets were not migrated. Create a new key to restore API access — the secret is shown only once.",
"etl.gaps.apiKeys.body": "Previous API keys weren't copied. Create a new key to restore API access the secret is shown only once.",
"etl.gaps.apiKeys.cta": "Open API keys",
"etl.gaps.blobs.title": "File contents (blobs)",
"etl.gaps.blobs.body": "Upload records may exist as metadata only. File bytes were not copied — re-upload CSVs or resync object storage if you need the originals.",
"etl.gaps.blobs.countHint": "{count} file records are metadata-only (bytes were not migrated).",
"etl.gaps.blobs.title": "Uploaded files",
"etl.gaps.blobs.body": "File names may appear even when the file itself wasn't copied. Re-upload CSVs if you need the originals.",
"etl.gaps.blobs.countHint": "{count} files are listed without their contents (they weren't copied).",
"etl.gaps.blobs.cta": "Open Files",
"etl.gaps.jobs.title": "Processing history",
"etl.gaps.jobs.body": "Legacy job and task history was not backfilled for cutover. New jobs you start here will appear normally.",
"etl.gaps.jobs.emptyHint": "Optional jobs domain was not run — empty history is expected.",
"etl.gaps.jobs.migratedHint": "{count} jobs were tagged migrated from an optional backfill.",
"etl.gaps.jobs.body": "Earlier job history wasn't imported. New jobs you start here will appear normally.",
"etl.gaps.jobs.emptyHint": "Processing history wasn't imported — an empty list is expected.",
"etl.gaps.jobs.migratedHint": "{count} jobs were imported from the previous workspace.",
"etl.gaps.jobs.cta": "Open Processing",
"etl.gaps.woo.title": "Store credentials",
"etl.gaps.woo.body": "WooCommerce and Shopify secrets may need to be re-entered after cutover — that is a reconnect step, not a broken sync.",
"etl.gaps.woo.body": "WooCommerce and Shopify passwords may need to be re-entered after the move — reconnect your store; sync is not broken.",
"etl.gaps.woo.cta": "Open Stores",
"etl.gaps.settings.title": "Company settings",
"etl.gaps.settings.body": "Only language and merge-products settings were imported. Re-check other company preferences in Settings.",
@@ -3690,6 +3690,7 @@ export const pl: MessageDict = {
"processing.receipt.cancelledDesc": "Właśnie uruchomione zadanie zostało anulowane.",
"processing.receipt.cancelFailed": "Nie można anulować zadania przetwarzania",
"unsubscribe.title": "Wypisz siÄ™",
"unsubscribe.useEmailLink": "Uzyj linku wypisania w e-mailu, ktory wyslalismy. Otwarcie tej strony bez tego linku nie zmieni preferencji e-mail.",
"unsubscribe.me": "Wypisz mnie",
"unsubscribe.invalidLink": "Nieprawidłowy link",
"unsubscribe.failedShort": "Wypisanie nie powiodło się",
@@ -4727,6 +4728,10 @@ export const pl: MessageDict = {
"seo.terms.description": "Warunki korzystania z Descrybe: import feedów, wzbogacanie katalogu, treści wspierane przez AI, eksporty oraz synchronizacja z WooCommerce dla Twojej firmy.",
"seo.features.title": "Funkcje — Feedy, wzbogacanie i eksport | Descrybe",
"seo.features.description": "Dowiedz się, jak Descrybe importuje feedy dostawców, mapuje pola na Twoją taksonomię, wzbogaca dane produktów i dostarcza katalogi przez feedy eksportowe, WooCommerce lub API.",
"seo.contactSales.title": "Kontakt ze sprzedaza | Descrybe",
"seo.contactSales.description": "Opowiedz nam o katalogu i potrzebach pojemnosci: przygotujemy plan niestandardowy, ktory oplacisz online.",
"seo.unsubscribe.title": "Wypisz sie | Descrybe",
"seo.unsubscribe.description": "Zatrzymaj e-maile marketingowe Descrybe, uzywajac linku wypisania w wiadomosci, ktora wyslalismy.",
"pricing.page.eyebrow": "Cennik",
"pricing.page.title": "Proste plany dla rosnących katalogów",
"pricing.page.lead": "Zacznij za darmo od mapowania feedów i podstawowego czyszczenia. Ulepsz, gdy potrzebujesz tytułów i opisów AI, większej liczby produktów, feedów eksportu lub synchronizacji WooCommerce — od Starter do Enterprise.",
@@ -4734,6 +4739,8 @@ export const pl: MessageDict = {
"pricing.page.subscribedMid": "lub porównaj plany w",
"pricing.page.plansLink": "Plany",
"pricing.page.subscribedAfter": ".",
"pricing.page.guestSubscribedBefore": "Masz juz subskrypcje?",
"pricing.page.guestSubscribedAfter": "aby zarzadzac platnosciami i porownac plany.",
"legal.lastUpdated": "Ostatnia aktualizacja: {date}",
"legal.backHome": "← Powrót do strony głównej",
"legal.emailLabel": "E-mail:",
@@ -5065,7 +5072,7 @@ export const pl: MessageDict = {
"brand.uploadLogo": "Prześlij logo",
"brand.clear": "Wyczyść",
"brand.field.logoUrl": "URL logo",
"brand.placeholder.logoUrl": "https://… lub przesłane /api/brand/logo/files/…",
"brand.placeholder.logoUrl": "https://",
"brand.logoAlt": "PodglÄ…d logo marki",
"brand.tipsTitle": "Wskazówki formuły / podglądu",
"brand.saving": "Zapisywanie…",
@@ -5226,6 +5233,8 @@ export const pl: MessageDict = {
"pricing.section.publicBefore": "Plany publiczne: Free, Starter, Growth, Business i Enterprise. Utwórz konto Free, a potem przejdź wyżej w",
"pricing.section.publicOr": "lub",
"pricing.section.publicAfter": "przez Stripe Checkout. Enterprise pozostaje obsługiwany przez sprzedaż.",
"pricing.section.guestPublicBefore": "Plany publiczne: Free, Starter, Plus, Growth, Business, Scale i Enterprise. Utworz konto Free, a potem",
"pricing.section.guestPublicAfter": "aby przejsc wyzej przez Stripe Checkout. Enterprise pozostaje obslugiwany przez sprzedaz.",
"pricing.section.plansLink": "Plany",
"pricing.section.billingLink": "Płatności",
"pricing.section.capabilitiesTitle": "Do czego jest stworzony każdy plan",
@@ -5291,7 +5300,7 @@ export const pl: MessageDict = {
"pricing.calc.enterpriseTitle": "Sounds like Enterprise",
"pricing.calc.enterpriseBody": "Your feeds or catalog size exceed self-serve caps. Talk to sales for unlimited capacity and a managed AI grant or BYOK.",
"pricing.calc.ctaPlan": "Choose {name}",
"pricing.calc.disclaimer": "Estimates use list prices and ~2 credits per AI product enhance. Checkout prices come from Stripe. A1 and other client deals stay admin-only.",
"pricing.calc.disclaimer": "Estimates use list prices and ~2 credits per AI product enhance. Checkout prices come from Stripe. Custom client deals stay admin-only.",
"pricing.card.mostPopular": "Najpopularniejszy",
"pricing.card.custom": "Indywidualny",
"pricing.card.forever": "na zawsze",
+28 -19
View File
@@ -78,7 +78,7 @@ export const pt: MessageDict = {
"auth.login.failed": "Falha no início de sessão",
"auth.login.passwordNotSet": "Esta conta ainda precisa de uma palavra-passe. Abra o link do convite ou peça a um administrador para emitir um novo.",
"auth.login.setPasswordFirstTitle": "Defina primeiro a palavra-passe:",
"auth.login.setPasswordFirstBody": "utilize o link do convite do seu e-mail. Se o link foi para um endereço antigo (desvio de e-mail), peça a um administrador da empresa para emitir um novo convite de definição de palavra-passe para {email}.",
"auth.login.setPasswordFirstBody": "utilize o link do convite do seu e-mail. Se precisar de um novo link, peça a um administrador da empresa para reemitir um link de definição de palavra-passe para {email}.",
"auth.login.yourEmail": "o seu e-mail",
"auth.login.platformAdminsReissue": "Os administradores da plataforma podem reemitir a partir de",
"auth.login.adminUsersLink": "Admin → Utilizadores",
@@ -144,9 +144,9 @@ export const pt: MessageDict = {
"auth.invite.acceptFailed": "Não foi possível aceitar o convite",
"auth.invite.setPasswordFailed": "Não foi possível definir a palavra-passe",
"auth.invite.expired": "Este convite é inválido ou expirou. Peça ao administrador da empresa para enviar um novo convite e abra o novo link (ou cole o novo token abaixo).",
"auth.invite.setPasswordExpired": "Este link de definição de palavra-passe é inválido ou expirou. Peça a um administrador da empresa ou da plataforma para o reemitir e abra o novo link (ou cole o novo token abaixo).",
"auth.invite.setPasswordExpired": "Este link de definição de palavra-passe é inválido ou expirou. Peça a um administrador da empresa para reemitir um link de definição de palavra-passe para este e-mail e abra o novo link (ou cole o novo token abaixo).",
"auth.invite.emailMismatchDefault": "Tem sessão iniciada com um e-mail diferente deste convite.",
"auth.invite.expiredFooter": "Link expirado? Peça a um administrador para o reemitir — não há API de reenvio self-service. Administradores da plataforma:",
"auth.invite.expiredFooter": "Link expirado? Peça a um administrador da empresa para o reemitir — não há API de reenvio self-service.",
"auth.invite.adminUsersLink": "Admin → Utilizadores",
"auth.invite.doneTitle": "Já faz parte da equipa",
"auth.invite.doneSetPasswordTitle": "Palavra-passe guardada",
@@ -163,7 +163,7 @@ export const pt: MessageDict = {
"auth.invite.switchAccountTitle": "Mudar de conta:",
"auth.invite.switchAccountBody": "termine a sessão e conclua este formulário com o e-mail convidado{emailSuffix}.",
"auth.invite.reissueTitle": "Caminho de reemissão:",
"auth.invite.reissueBody": "se o seu e-mail real de início de sessão mudou (desvio de e-mail), peça a um administrador da empresa para revogar este convite e enviar um novo para o e-mail que utiliza para iniciar sessão. Os administradores da plataforma também podem reemitir links de definição de palavra-passe em Admin → Utilizadores.",
"auth.invite.reissueBody": "peça a um administrador da empresa para reemitir um link de definição de palavra-passe para este e-mail, ou para revogar este convite e enviar um novo para o e-mail que utiliza para iniciar sessão.",
"locale.switcher": "Idioma da interface",
"locale.menu": "Escolher idioma da interface",
"locale.label": "Idioma do painel",
@@ -276,7 +276,7 @@ export const pt: MessageDict = {
"settings.table.lastUsed": "Última utilização",
"dashboard.demoSandbox": "Sandbox de demonstração",
"dashboard.demoEmptyTitle": "A sandbox de demonstração está vazia",
"dashboard.demoEmptyHint": "A sandbox de demonstração está vazia — mude para A1 ou ligue um feed para ver estatísticas reais do catálogo.",
"dashboard.demoEmptyHint": "A sandbox de demonstração está vazia ligue um feed para ver estatísticas reais do catálogo.",
"dashboard.overviewTitle": "Visão geral",
"dashboard.quickLinks": "Ligações rápidas",
"dashboard.whatsNew": "Novidades",
@@ -319,7 +319,7 @@ export const pt: MessageDict = {
"dashboard.workflow.exportMap": "Mapear e exportar",
"dashboard.workflowHint": "Catálogo a entrar → enriquecer / processar → exportar ou enviar para lojas. Salte para o passo seguinte para {name}.",
"dashboard.overviewHint": "Totais em direto para {name}",
"dashboard.demoEmptyMessage": "Mude para A1 (ou outra empresa com dados) no cabeçalho, ou ligue um feed aqui para preencher esta sandbox.",
"dashboard.demoEmptyMessage": "Ligue um feed aqui para preencher esta sandbox, ou mude para outra empresa no cabeçalho.",
"dashboard.emptyTitle": "Importe um catálogo",
"dashboard.emptyMessage": "Catálogo a entrar → enriquecer / processar → exportar ou enviar para lojas. Comece com Primeiros passos abaixo (ou ligue um feed / carregue um CSV).",
"dashboard.connectFeedAnyway": "Ligar feed mesmo assim",
@@ -367,29 +367,29 @@ export const pt: MessageDict = {
"activation.focus.sync": "Seguinte: sincronize uma amostra depois de guardar o mapeamento. Use Sync num feed mapeado — evite origens sem mapa.",
"activation.focus.dismiss": "Fechar dica",
"activation.dismiss": "Dispensar lista",
"hypercare.report.title": "Hypercare de cutover",
"hypercare.report.title": "Precisa de ajuda após a mudança?",
"hypercare.report.message": "Dados em falta ou incorretos após a migração? Reporte para podermos corrigir o seu espaço de trabalho.",
"hypercare.report.cta": "Reportar dados em falta ou incorretos",
"hypercare.report.dismiss": "Dispensar",
"hypercare.report.adminTriage": "Abrir fila de hypercare",
"etl.gaps.title": "What did not migrate",
"etl.gaps.message": "These areas were intentionally left empty or partial after the Descrybe platform cutover. Nothing is “broken” — recreate or reconnect as needed.",
"etl.gaps.title": "What still needs setup",
"etl.gaps.message": "Some items weren't copied when we moved your workspace. Nothing is broken — recreate or reconnect as needed.",
"etl.gaps.dismiss": "Dismiss",
"etl.gaps.apiKeys.title": "API keys",
"etl.gaps.apiKeys.body": "Legacy API key secrets were not migrated. Create a new key to restore API access — the secret is shown only once.",
"etl.gaps.apiKeys.body": "Previous API keys weren't copied. Create a new key to restore API access the secret is shown only once.",
"etl.gaps.apiKeys.cta": "Open API keys",
"etl.gaps.blobs.title": "File contents (blobs)",
"etl.gaps.blobs.body": "Upload records may exist as metadata only. File bytes were not copied — re-upload CSVs or resync object storage if you need the originals.",
"etl.gaps.blobs.countHint": "{count} file records are metadata-only (bytes were not migrated).",
"etl.gaps.blobs.title": "Uploaded files",
"etl.gaps.blobs.body": "File names may appear even when the file itself wasn't copied. Re-upload CSVs if you need the originals.",
"etl.gaps.blobs.countHint": "{count} files are listed without their contents (they weren't copied).",
"etl.gaps.blobs.cta": "Open Files",
"etl.gaps.jobs.title": "Processing history",
"etl.gaps.jobs.body": "Legacy job and task history was not backfilled for cutover. New jobs you start here will appear normally.",
"etl.gaps.jobs.emptyHint": "Optional jobs domain was not run — empty history is expected.",
"etl.gaps.jobs.migratedHint": "{count} jobs were tagged migrated from an optional backfill.",
"etl.gaps.jobs.body": "Earlier job history wasn't imported. New jobs you start here will appear normally.",
"etl.gaps.jobs.emptyHint": "Processing history wasn't imported — an empty list is expected.",
"etl.gaps.jobs.migratedHint": "{count} jobs were imported from the previous workspace.",
"etl.gaps.jobs.cta": "Open Processing",
"etl.gaps.woo.title": "Store credentials",
"etl.gaps.woo.body": "WooCommerce and Shopify secrets may need to be re-entered after cutover — that is a reconnect step, not a broken sync.",
"etl.gaps.woo.body": "WooCommerce and Shopify passwords may need to be re-entered after the move — reconnect your store; sync is not broken.",
"etl.gaps.woo.cta": "Open Stores",
"etl.gaps.settings.title": "Company settings",
"etl.gaps.settings.body": "Only language and merge-products settings were imported. Re-check other company preferences in Settings.",
@@ -3690,6 +3690,7 @@ export const pt: MessageDict = {
"processing.receipt.cancelledDesc": "O trabalho recém-iniciado foi cancelado.",
"processing.receipt.cancelFailed": "Não foi possível cancelar o trabalho de processamento",
"unsubscribe.title": "Cancelar subscrição",
"unsubscribe.useEmailLink": "Use a ligacao de cancelamento no email que lhe enviamos. Abrir esta pagina sem essa ligacao nao pode alterar as suas preferencias de email.",
"unsubscribe.me": "Cancelar a minha subscrição",
"unsubscribe.invalidLink": "Ligação inválida",
"unsubscribe.failedShort": "Falha ao cancelar a subscrição",
@@ -4727,6 +4728,10 @@ export const pt: MessageDict = {
"seo.terms.description": "Termos de utilização da Descrybe: importação de feeds, enriquecimento de catálogo, conteúdo assistido por IA, exportações e sincronização com WooCommerce para o seu negócio.",
"seo.features.title": "Funcionalidades — Feeds, enriquecimento e exportação | Descrybe",
"seo.features.description": "Descubra como a Descrybe importa feeds de fornecedores, mapeia campos para a sua taxonomia, enriquece dados de produto e envia catálogos através de feeds de exportação, WooCommerce ou API.",
"seo.contactSales.title": "Contactar vendas | Descrybe",
"seo.contactSales.description": "Fale-nos do seu catalogo e necessidades de capacidade: prepararemos um plano personalizado que pode pagar online.",
"seo.unsubscribe.title": "Cancelar subscricao | Descrybe",
"seo.unsubscribe.description": "Pare os emails de marketing da Descrybe usando a ligacao de cancelamento na mensagem que lhe enviamos.",
"pricing.page.eyebrow": "Preços",
"pricing.page.title": "Planos simples para catálogos em crescimento",
"pricing.page.lead": "Comece grátis com mapeamento de feeds e limpeza básica. Faça upgrade quando precisar de títulos e descrições com IA, mais produtos, feeds de exportação ou sync WooCommerce — de Starter a Enterprise.",
@@ -4734,6 +4739,8 @@ export const pt: MessageDict = {
"pricing.page.subscribedMid": "ou comparar planos em",
"pricing.page.plansLink": "Planos",
"pricing.page.subscribedAfter": ".",
"pricing.page.guestSubscribedBefore": "Ja e subscritor?",
"pricing.page.guestSubscribedAfter": "para gerir a faturacao e comparar planos.",
"legal.lastUpdated": "Última atualização: {date}",
"legal.backHome": "← Voltar ao início",
"legal.emailLabel": "E-mail:",
@@ -5065,7 +5072,7 @@ export const pt: MessageDict = {
"brand.uploadLogo": "Carregar logótipo",
"brand.clear": "Limpar",
"brand.field.logoUrl": "URL do logótipo",
"brand.placeholder.logoUrl": "https://… ou carregado /api/brand/logo/files/…",
"brand.placeholder.logoUrl": "https://",
"brand.logoAlt": "Pré-visualização do logótipo da marca",
"brand.tipsTitle": "Dicas de fórmula / pré-visualização",
"brand.saving": "A guardar…",
@@ -5226,6 +5233,8 @@ export const pt: MessageDict = {
"pricing.section.publicBefore": "Planos públicos: Free, Starter, Growth, Business e Enterprise. Crie uma conta Free e depois faça upgrade em",
"pricing.section.publicOr": "ou",
"pricing.section.publicAfter": "via Stripe Checkout. O Enterprise continua a ser gerido pelas vendas.",
"pricing.section.guestPublicBefore": "Planos publicos: Free, Starter, Plus, Growth, Business, Scale e Enterprise. Crie uma conta Free e depois",
"pricing.section.guestPublicAfter": "para fazer upgrade via Stripe Checkout. O Enterprise continua a ser gerido pelas vendas.",
"pricing.section.plansLink": "Planos",
"pricing.section.billingLink": "Faturação",
"pricing.section.capabilitiesTitle": "Para que está pensado cada plano",
@@ -5291,7 +5300,7 @@ export const pt: MessageDict = {
"pricing.calc.enterpriseTitle": "Sounds like Enterprise",
"pricing.calc.enterpriseBody": "Your feeds or catalog size exceed self-serve caps. Talk to sales for unlimited capacity and a managed AI grant or BYOK.",
"pricing.calc.ctaPlan": "Choose {name}",
"pricing.calc.disclaimer": "Estimates use list prices and ~2 credits per AI product enhance. Checkout prices come from Stripe. A1 and other client deals stay admin-only.",
"pricing.calc.disclaimer": "Estimates use list prices and ~2 credits per AI product enhance. Checkout prices come from Stripe. Custom client deals stay admin-only.",
"pricing.card.mostPopular": "Mais popular",
"pricing.card.custom": "Personalizado",
"pricing.card.forever": "para sempre",
+80
View File
@@ -0,0 +1,80 @@
/**
* Merchant-facing copy: no A1 / seeded-company, cutover jargon, ETL/blobs/domain,
* or brand-logo API paths in customer-visible strings.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { UI_LOCALES } from "./i18n/locales.ts";
import { en } from "./i18n/messages/en.ts";
import { loadAllMessages, messagesFor } from "./i18n/messages/catalog.ts";
import {
DEFAULT_ENHANCE_PREAMBLE,
DEFAULT_SECTION_BODIES
} from "./categories/prompt-sections.ts";
const CUSTOMER_DEMO_EMPTY_KEYS = ["dashboard.demoEmptyHint", "dashboard.demoEmptyMessage"] as const;
const CUSTOMER_ETL_KEYS = [
"etl.gaps.title",
"etl.gaps.message",
"etl.gaps.blobs.title",
"etl.gaps.blobs.body",
"etl.gaps.blobs.countHint",
"etl.gaps.jobs.body",
"etl.gaps.jobs.emptyHint",
"etl.gaps.jobs.migratedHint",
"etl.gaps.woo.body"
] as const;
describe("customer-visible copy leaks", () => {
it("English demo empty, hypercare, ETL, and logo placeholder stay merchant-facing", () => {
for (const key of CUSTOMER_DEMO_EMPTY_KEYS) {
assert.doesNotMatch(en[key], /\bA1\b/i, key);
assert.doesNotMatch(en[key], /seeded company/i, key);
}
assert.doesNotMatch(en["hypercare.report.title"], /cutover|hypercare/i);
assert.doesNotMatch(en["hypercare.report.message"], /cutover|hypercare/i);
for (const key of CUSTOMER_ETL_KEYS) {
assert.doesNotMatch(en[key], /\bETL\b/, key);
assert.doesNotMatch(en[key], /\bblobs?\b/i, key);
assert.doesNotMatch(en[key], /\bdomain\b/i, key);
assert.doesNotMatch(en[key], /cutover/i, key);
}
assert.doesNotMatch(en["brand.placeholder.logoUrl"], /\/api\/brand\/logo\/files/i);
});
it("every UI locale keeps demo empty, hypercare title, ETL, and logo copy merchant-facing", async () => {
await loadAllMessages();
for (const { code } of UI_LOCALES) {
const pack = code === "en" ? en : messagesFor(code);
for (const key of CUSTOMER_DEMO_EMPTY_KEYS) {
const text = String(pack[key] ?? "");
assert.doesNotMatch(text, /\bA1\b/i, `${code}:${key}`);
assert.doesNotMatch(text, /seeded company/i, `${code}:${key}`);
}
assert.doesNotMatch(
String(pack["hypercare.report.title"] ?? ""),
/cutover|hypercare/i,
`${code}:hypercare.report.title`
);
for (const key of CUSTOMER_ETL_KEYS) {
const text = String(pack[key] ?? "");
assert.doesNotMatch(text, /\bETL\b/, `${code}:${key}`);
assert.doesNotMatch(text, /\bblobs?\b/i, `${code}:${key}`);
assert.doesNotMatch(text, /\bdomain\b/i, `${code}:${key}`);
}
assert.doesNotMatch(
String(pack["brand.placeholder.logoUrl"] ?? ""),
/\/api\/brand\/logo\/files/i,
`${code}:brand.placeholder.logoUrl`
);
}
});
it("category prompt defaults hide JSON schema jargon from customer text", () => {
const blob = [DEFAULT_ENHANCE_PREAMBLE, ...Object.values(DEFAULT_SECTION_BODIES)].join("\n");
assert.doesNotMatch(blob, /system schema/i);
assert.doesNotMatch(blob, /your reply is parsed as json/i);
assert.doesNotMatch(blob, /build json/i);
});
});
@@ -3,9 +3,15 @@ import { describe, it } from "node:test";
import { isHttpError } from "@sveltejs/kit";
import type { RequestEvent } from "@sveltejs/kit";
import {
adminLoginRedirect,
assertSameOrigin,
fetchMeStaff,
isAdminBootstrapPath,
isAdminSupportPath,
isFullPlatformAdmin,
requirePlatformAdminServer
isSupportDesk,
requirePlatformAdminServer,
requireSupportDeskServer
} from "./require-platform-admin.ts";
function makeEvent(opts: {
@@ -122,6 +128,69 @@ describe("requirePlatformAdminServer", () => {
});
});
describe("fetchMeStaff", () => {
it("returns 401 without throwing when /me is unauthorized", async () => {
const event = makeEvent({
fetchImpl: async () => new Response("{}", { status: 401 })
});
assert.deepEqual(await fetchMeStaff(event), { ok: false, status: 401 });
});
});
describe("requireSupportDeskServer", () => {
it("allows support_desk without full_admin", async () => {
const me = { staff_access: { full_admin: false, support_desk: true } };
const event = makeEvent({
fetchImpl: async () =>
new Response(JSON.stringify(me), {
status: 200,
headers: { "content-type": "application/json" }
})
});
assert.deepEqual(await requireSupportDeskServer(event), me);
});
it("403 for tenant with no staff_access", async () => {
const event = makeEvent({
fetchImpl: async () =>
new Response(JSON.stringify({ user: { is_platform_admin: false } }), {
status: 200,
headers: { "content-type": "application/json" }
})
});
await expectHttpAsync(403, () => requireSupportDeskServer(event));
});
});
describe("admin path split and login next=", () => {
it("classifies support vs bootstrap vs other admin", () => {
assert.equal(isAdminSupportPath("/admin/support"), true);
assert.equal(isAdminSupportPath("/admin/support/tickets/1"), true);
assert.equal(isAdminSupportPath("/admin"), false);
assert.equal(isAdminSupportPath("/admin/logs"), false);
assert.equal(isAdminBootstrapPath("/admin/bootstrap"), true);
assert.equal(isAdminBootstrapPath("/admin/logs"), false);
});
it("builds login redirect with next=", () => {
assert.equal(
adminLoginRedirect("/admin/logs", "?x=1"),
"/login?next=%2Fadmin%2Flogs%3Fx%3D1"
);
});
it("isSupportDesk follows staff_access.support_desk", () => {
assert.equal(isSupportDesk({ staff_access: { support_desk: true } }), true);
assert.equal(
isSupportDesk({
staff_access: { full_admin: false, support_desk: false },
user: { is_platform_admin: true }
}),
false
);
});
});
describe("assertSameOrigin", () => {
it("allows matching Origin", () => {
assertSameOrigin(
@@ -7,39 +7,90 @@ import { error } from "@sveltejs/kit";
import type { RequestEvent } from "@sveltejs/kit";
import {
isFullPlatformAdmin,
isSupportDesk,
type StaffAccessMe
} from "../staff-access.ts";
export { isFullPlatformAdmin } from "../staff-access.ts";
export { isFullPlatformAdmin, isSupportDesk } from "../staff-access.ts";
type MeStaff = StaffAccessMe;
export type FetchMeStaffResult =
| { ok: true; me: MeStaff }
| { ok: false; status: 401 | 403 | 502 };
/** Absolute /api/auth/me when PUBLIC_API_URL is set (Compose/adapter-node); else Vite same-origin proxy. */
function authMeUrl(): string {
const base = (process.env.PUBLIC_API_URL ?? "").replace(/\/$/, "");
return base ? `${base}/api/auth/me` : "/api/auth/me";
}
/** `/admin/support` desk pages — full admin or support_staff. */
export function isAdminSupportPath(pathname: string): boolean {
return pathname === "/admin/support" || pathname.startsWith("/admin/support/");
}
/** First-run bootstrap UI — full platform admin only. */
export function isAdminBootstrapPath(pathname: string): boolean {
return pathname === "/admin/bootstrap" || pathname.startsWith("/admin/bootstrap/");
}
/** Open-redirect-safe login URL with next= pointing at the current admin path. */
export function adminLoginRedirect(pathname: string, search = ""): string {
const next = `${pathname}${search}`;
return `/login?next=${encodeURIComponent(next)}`;
}
/**
* Server-side platform-admin gate for SvelteKit endpoints.
* Uses event.fetch so session cookies reach the Go /api/auth/me (Vite proxy or PUBLIC_API_URL).
* Fetch /api/auth/me without throwing. Layout uses this to redirect 401 login.
* API +server.ts handlers still use requirePlatformAdminServer (error 401/403).
*/
export async function requirePlatformAdminServer(event: RequestEvent): Promise<MeStaff> {
export async function fetchMeStaff(event: RequestEvent): Promise<FetchMeStaffResult> {
const headers: Record<string, string> = { Accept: "application/json" };
const cookie = event.request.headers.get("cookie");
if (cookie) headers.cookie = cookie;
const res = await event.fetch(authMeUrl(), { headers });
if (res.status === 401) {
error(401, "Authentication required.");
return { ok: false, status: 401 };
}
if (!res.ok) {
error(res.status === 403 ? 403 : 502, "Failed to verify admin access.");
return { ok: false, status: res.status === 403 ? 403 : 502 };
}
const me = (await res.json()) as MeStaff;
if (!isFullPlatformAdmin(me)) {
return { ok: true, me };
}
/**
* Server-side platform-admin gate for SvelteKit endpoints.
* Uses event.fetch so session cookies reach the Go /api/auth/me (Vite proxy or PUBLIC_API_URL).
*/
export async function requirePlatformAdminServer(event: RequestEvent): Promise<MeStaff> {
const result = await fetchMeStaff(event);
if (!result.ok) {
if (result.status === 401) {
error(401, "Authentication required.");
}
error(result.status === 403 ? 403 : 502, "Failed to verify admin access.");
}
if (!isFullPlatformAdmin(result.me)) {
error(403, "Platform admin required.");
}
return me;
return result.me;
}
/** Server-side support-desk gate (full admin or support_staff). */
export async function requireSupportDeskServer(event: RequestEvent): Promise<MeStaff> {
const result = await fetchMeStaff(event);
if (!result.ok) {
if (result.status === 401) {
error(401, "Authentication required.");
}
error(result.status === 403 ? 403 : 502, "Failed to verify support access.");
}
if (!isSupportDesk(result.me)) {
error(403, "Support desk access required.");
}
return result.me;
}
function originOf(url: string): string | null {
+22 -1
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { isFullPlatformAdmin, shouldUnlockAllFeatures } from "./staff-access.ts";
import { isFullPlatformAdmin, isSupportDesk, shouldUnlockAllFeatures } from "./staff-access.ts";
describe("isFullPlatformAdmin", () => {
it("allows staff_access.full_admin", () => {
@@ -39,6 +39,27 @@ describe("isFullPlatformAdmin", () => {
});
});
describe("isSupportDesk", () => {
it("allows support_desk", () => {
assert.equal(isSupportDesk({ staff_access: { support_desk: true } }), true);
});
it("denies tenant even when legacy is_platform_admin is set alongside staff_access", () => {
assert.equal(
isSupportDesk({
staff_access: { full_admin: false, support_desk: false },
user: { is_platform_admin: true }
}),
false
);
});
it("legacy: allows is_platform_admin when staff_access is absent", () => {
assert.equal(isSupportDesk({ user: { is_platform_admin: true } }), true);
assert.equal(isSupportDesk({ user: { is_platform_admin: false } }), false);
});
});
describe("shouldUnlockAllFeatures", () => {
it("unlocks Demo / full admin", () => {
assert.equal(
+9
View File
@@ -24,6 +24,15 @@ export function isFullPlatformAdmin(me: StaffAccessMe | null | undefined): boole
return Boolean(me.user?.is_platform_admin);
}
/** Full admin or support_staff (legacy: is_platform_admin when staff_access is absent). */
export function isSupportDesk(me: StaffAccessMe | null | undefined): boolean {
if (me == null) return false;
if (me.staff_access) {
return Boolean(me.staff_access.support_desk);
}
return Boolean(me.user?.is_platform_admin);
}
/**
* Nav / shell preview unlock for Demo + platform staff.
* Must stay false for normal A1 (and other tenant) users even if a stale
+30 -19
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { api, ApiError } from "$lib/api";
import { consumeAuthLinkSecrets } from "$lib/auth-link-token";
import { trackEvent } from "$lib/analytics";
import { writeActivationProgress } from "$lib/activation";
import { i18n } from "$lib/i18n";
@@ -27,12 +27,11 @@
mismatch: boolean;
};
const initialToken = page.url.searchParams.get("token") ?? "";
const initialMode = page.url.searchParams.get("mode");
let token = $state(initialToken);
/** True when the token arrived via ?token=; never show it in a visible input. */
let tokenFromLink = $state(initialToken.length > 0);
let token = $state("");
/** True when the token arrived via hash or query link; never show it in a visible input. */
let tokenFromLink = $state(false);
/** Fragment tokens are client-only; wait for mount before treating the token as missing. */
let hydrated = $state(false);
let name = $state("");
let password = $state("");
let error = $state("");
@@ -44,9 +43,7 @@
let sessionEmail = $state("");
let signingOut = $state(false);
/** Admin HMAC emails use mode=set-password; migrator invites use the invite token path. */
let mode = $state<"invite" | "set-password">(
initialMode === "set-password" ? "set-password" : "invite"
);
let mode = $state<"invite" | "set-password">("invite");
const errorId = "accept-invite-form-error";
const passwordHintId = "accept-invite-password-hint";
@@ -103,12 +100,21 @@
}
onMount(() => {
if (!tokenFromLink) return;
// Drop the secret from the address bar / history (referrer + shoulder-surf).
const cleaned = new URL(window.location.href);
cleaned.searchParams.delete("token");
history.replaceState(history.state, "", cleaned.pathname + cleaned.search + cleaned.hash);
const consumed = consumeAuthLinkSecrets(window.location.href);
if (consumed.token) {
token = consumed.token;
tokenFromLink = true;
}
if (consumed.mode === "set-password") {
mode = "set-password";
}
hydrated = true;
if (consumed.stripped) {
history.replaceState(history.state, "", consumed.cleanedPathSearch);
}
if (tokenFromLink) {
void loadPreview(token);
}
});
async function onSubmit(event: Event) {
@@ -179,7 +185,15 @@
</script>
<Card>
{#if done}
{#if !hydrated}
<CardHeader>
<CardTitle level={1}>{i18n.t("auth.invite.title")}</CardTitle>
<CardDescription>{i18n.t("auth.invite.description")}</CardDescription>
</CardHeader>
<CardContent>
<p class="text-sm text-text-muted" aria-live="polite">{i18n.t("auth.invite.checking")}</p>
</CardContent>
{:else if done}
<CardHeader>
<CardTitle level={1}>
{mode === "set-password"
@@ -377,9 +391,6 @@
</p>
<p class="mt-1.5 text-center text-xs text-text-muted">
{i18n.t("auth.invite.expiredFooter")}
<a href="/admin/users" class="font-medium text-link hover:underline"
>{i18n.t("auth.invite.adminUsersLink")}</a
>.
</p>
</CardContent>
{/if}
@@ -0,0 +1,46 @@
import { error, redirect } from "@sveltejs/kit";
import type { LayoutServerLoad } from "./$types";
import {
adminLoginRedirect,
fetchMeStaff,
isAdminSupportPath,
isFullPlatformAdmin,
isSupportDesk
} from "$lib/server/require-platform-admin";
/**
* SSR gate for /admin/* pages (not +server.ts endpoints).
* Unauthenticated login?next=. Non-staff /dashboard (no admin chrome).
* /admin/support* support desk. All other admin pages (including logs + bootstrap) full platform admin.
*/
export const load: LayoutServerLoad = async (event) => {
const result = await fetchMeStaff(event);
if (!result.ok) {
if (result.status === 401) {
redirect(303, adminLoginRedirect(event.url.pathname, event.url.search));
}
error(result.status, "Forbidden");
}
const me = result.me;
const path = event.url.pathname;
const supportPath = isAdminSupportPath(path);
const fullAdmin = isFullPlatformAdmin(me);
const supportDesk = isSupportDesk(me);
if (supportPath) {
if (!supportDesk) {
redirect(303, "/dashboard");
}
return { staff: { full_admin: fullAdmin, support_desk: true } };
}
if (!fullAdmin) {
if (supportDesk) {
redirect(303, "/admin/support");
}
redirect(303, "/dashboard");
}
return { staff: { full_admin: true, support_desk: supportDesk } };
};
+11 -11
View File
@@ -264,18 +264,18 @@
});
</script>
<PageShell
{#if loading}
<Spinner />
{:else if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else if error}
<Alert message={error} />
{:else}
<PageShell
eyebrow={i18n.t("admin.overview.eyebrow")}
title={i18n.t("admin.overview.title")}
description={i18n.t("admin.overview.description")}
>
{#if loading}
<Spinner />
{:else if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else if error}
<Alert message={error} />
{:else}
>
{#if summary}
<section class="space-y-3" aria-labelledby="admin-kpi-heading">
<div class="flex flex-wrap items-end justify-between gap-2">
@@ -517,5 +517,5 @@
</div>
</section>
{/if}
{/if}
</PageShell>
</PageShell>
{/if}
@@ -1,7 +1,8 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { api, failureMessage, isForbidden, isUnauthorized } from "$lib/api";
import { requirePlatformAdmin } from "$lib/admin-gate";
import { isFullPlatformAdmin } from "$lib/staff-access";
import type { MeResponse } from "$lib/types";
import PageShell from "$lib/components/PageShell.svelte";
import Spinner from "$lib/components/Spinner.svelte";
@@ -23,33 +24,33 @@
let me = $state<MeResponse | null>(null);
onMount(async () => {
try {
me = await api<MeResponse>("/api/auth/me");
} catch (err) {
if (isUnauthorized(err)) {
const gate = await requirePlatformAdmin();
if (!gate.ok) {
if (gate.reason === "auth") {
await goto("/login");
return;
}
if (isForbidden(err)) {
accessDenied = true;
if (gate.reason !== "forbidden") {
error = gate.message;
}
loading = false;
return;
}
error = failureMessage(err, i18n.t("admin.bootstrap.loadFailed"));
} finally {
me = gate.me;
loading = false;
}
});
</script>
<PageShell
{#if loading}
<Spinner />
{:else if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else}
<PageShell
title={i18n.t("admin.bootstrap.title")}
description={i18n.t("admin.bootstrap.description")}
>
{#if loading}
<Spinner />
{:else if accessDenied}
<ForbiddenEmptyState kind="platform" />
{:else}
>
<Card class="mx-auto max-w-md">
<CardHeader>
<CardTitle>{i18n.t("admin.bootstrap.statusTitle")}</CardTitle>
@@ -58,11 +59,11 @@
</CardDescription>
</CardHeader>
<CardContent>
{#if me?.user?.is_platform_admin}
{#if isFullPlatformAdmin(me)}
<div class="rounded-md border border-green-500/30 bg-green-500/10 p-4 text-sm">
<p class="font-medium">{i18n.t("admin.bootstrap.alreadyAdmin")}</p>
<p class="mt-2 text-muted-foreground">
{i18n.t("admin.bootstrap.signedInAs", { email: me.user.email })}
{i18n.t("admin.bootstrap.signedInAs", { email: me?.user?.email ?? "" })}
</p>
</div>
{:else if error}
@@ -81,7 +82,7 @@
{/if}
</CardContent>
<CardFooter class="flex justify-between gap-2">
{#if me?.user?.is_platform_admin}
{#if isFullPlatformAdmin(me)}
<Button class="w-full" onclick={() => goto("/admin")}>{i18n.t("admin.bootstrap.goToPanel")}</Button>
{:else}
<Button variant="outline" class="w-full" onclick={() => goto("/dashboard")}>
@@ -90,5 +91,5 @@
{/if}
</CardFooter>
</Card>
{/if}
</PageShell>
</PageShell>
{/if}
@@ -0,0 +1,7 @@
import { redirect } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";
/** Legacy `/admin/logs` → diagnostics. Layout.server already requires platform admin. */
export const load: PageServerLoad = () => {
redirect(307, "/admin/diagnostics");
};
+1 -1
View File
@@ -528,7 +528,7 @@
const link =
res.accept_url?.trim() ||
(res.token
? `${window.location.origin}/accept-invite?token=${encodeURIComponent(res.token)}`
? `${window.location.origin}/accept-invite#token=${encodeURIComponent(res.token)}`
: "");
if (link) {
inviteAcceptLink = link;
+19 -3
View File
@@ -5,7 +5,9 @@
import { DEMO_BOOKING_URL } from "$lib/site";
import { submitSalesContact } from "$lib/sales-contact";
import { notifySuccess, notifyApiError } from "$lib/notify";
import PageShell from "$lib/components/PageShell.svelte";
import SiteHeader from "$lib/components/site/SiteHeader.svelte";
import Footer from "$lib/components/site/Footer.svelte";
import SeoHead from "$lib/components/site/SeoHead.svelte";
import Alert from "$lib/components/Alert.svelte";
import {
Button,
@@ -65,7 +67,19 @@
}
</script>
<PageShell title={i18n.t("sales.contact.title")} description={i18n.t("sales.contact.lead")}>
<SeoHead
title={i18n.t("seo.contactSales.title")}
description={i18n.t("seo.contactSales.description")}
path="/contact-sales"
/>
<div class="flex min-h-screen flex-col bg-background">
<SiteHeader />
<main id="main-content" class="mx-auto w-full max-w-3xl flex-1 px-4 pt-28 pb-12 sm:px-6">
<div class="mb-8">
<h1 class="text-4xl font-bold tracking-tight text-text">{i18n.t("sales.contact.title")}</h1>
<p class="mt-2 text-lg text-text-muted">{i18n.t("sales.contact.lead")}</p>
</div>
{#if done}
<Card>
<CardHeader>
@@ -152,4 +166,6 @@
</CardContent>
</Card>
{/if}
</PageShell>
</main>
<Footer />
</div>
+8 -1
View File
@@ -19,6 +19,7 @@
loadDocsAuthStatus,
maskKeyPrefix,
readStoredTryItKey,
relabelRapiDocAuthRequired,
resolveDocsTryItKey,
writeStoredTryItKey,
type DocsAuthStatus,
@@ -54,6 +55,7 @@
let runtimeWarning = $state("");
/** $state so theme $effect re-runs after mount + SiteHeader toggle. */
let rapiEl = $state<RapiDocElement | undefined>(undefined);
let authLabelObserver: MutationObserver | null = null;
let askOpen = $state(false);
let mountGeneration = 0;
let hydrateCheckTimer: number | undefined;
@@ -121,6 +123,8 @@
window.clearTimeout(hydrateCheckTimer);
window.removeEventListener("error", onWindowError);
window.removeEventListener("unhandledrejection", onRejection);
authLabelObserver?.disconnect();
authLabelObserver = null;
rapiEl?.remove();
rapiEl = undefined;
};
@@ -323,6 +327,8 @@
loadError = "";
runtimeWarning = "";
window.clearTimeout(hydrateCheckTimer);
authLabelObserver?.disconnect();
authLabelObserver = null;
rapiEl?.remove();
rapiEl = undefined;
@@ -338,6 +344,7 @@
return;
}
rapiEl = instance;
authLabelObserver = relabelRapiDocAuthRequired(instance);
} catch (err) {
if (gen !== mountGeneration) return;
loadState = "error";
@@ -533,7 +540,7 @@
{/if}
</p>
{:else}
<p class="mt-1 text-xs text-text-muted" data-testid="docs-tryit-no-keys-legacy">
<p class="mt-1 text-xs text-text-muted" data-testid="docs-tryit-no-keys">
{docsAuth.canCreateKey
? i18n.t("docs.tryIt.noKeysLegacyAdmin")
: i18n.t("docs.tryIt.noKeysLegacyMember")}
@@ -470,7 +470,7 @@
</div>
<div class="space-y-2">
<Label for="custom_model">{i18n.t("ai.field.model")}</Label>
<Input id="custom_model" bind:value={model} placeholder="gpt-4o-mini" autocomplete="off" />
<Input id="custom_model" bind:value={model} placeholder={i18n.t("ai.placeholder.modelId")} autocomplete="off" />
</div>
</CardContent>
</Card>
@@ -494,7 +494,7 @@
id="api_key"
type="password"
bind:value={apiKey}
placeholder={hasApiKey ? i18n.t("ai.placeholder.keepKey") : "sk-…"}
placeholder={hasApiKey ? i18n.t("ai.placeholder.keepKey") : i18n.t("ai.field.apiKey")}
autocomplete="new-password"
/>
</div>
-6
View File
@@ -95,12 +95,6 @@
email: email || i18n.t("auth.login.yourEmail")
})}
</p>
<p>
{i18n.t("auth.login.platformAdminsReissue")}
<a href="/admin/users" class="font-medium text-link hover:underline"
>{i18n.t("auth.login.adminUsersLink")}</a
>.
</p>
<p>
<a href="/accept-invite" class="font-medium text-link hover:underline"
>{i18n.t("auth.login.haveToken")}</a
+3 -8
View File
@@ -28,14 +28,9 @@
</p>
<MarketingAuthCtas class="mt-8" />
<p class="mt-4 text-sm text-text-muted">
{i18n.t("pricing.page.subscribedBefore")}
<a href="/billing" class="font-medium text-link hover:underline"
>{i18n.t("nav.billing")}</a
>
{i18n.t("pricing.page.subscribedMid")}
<a href="/plans" class="font-medium text-link hover:underline"
>{i18n.t("pricing.page.plansLink")}</a
>{i18n.t("pricing.page.subscribedAfter")}
{i18n.t("pricing.page.guestSubscribedBefore")}
<a href="/login" class="font-medium text-link hover:underline">{i18n.t("site.logIn")}</a>
{i18n.t("pricing.page.guestSubscribedAfter")}
</p>
</section>
<PricingSection hideIntro />
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from "svelte";
import { page } from "$app/state";
import { api, ApiError } from "$lib/api";
import { consumeAuthLinkSecrets } from "$lib/auth-link-token";
import { apiFormError, fieldDescribedBy, fieldInvalid } from "$lib/api-form-error";
import { i18n } from "$lib/i18n";
import {
@@ -17,12 +17,6 @@
Label
} from "$lib/components/ui";
function tokenFromHash(hash: string): string {
const raw = hash.startsWith("#") ? hash.slice(1) : hash;
const params = new URLSearchParams(raw);
return (params.get("token") ?? "").trim();
}
let token = $state("");
/** True when the token arrived via link; never show it in a visible input. */
let tokenFromLink = $state(false);
@@ -39,20 +33,15 @@
const passwordHintId = "reset-password-hint";
onMount(() => {
const fromHash = tokenFromHash(window.location.hash);
const fromQuery = (new URL(window.location.href).searchParams.get("token") ?? "").trim();
const fromSsrQuery = (page.url.searchParams.get("token") ?? "").trim();
const resolved = fromHash || fromQuery || fromSsrQuery;
if (resolved) {
token = resolved;
const consumed = consumeAuthLinkSecrets(window.location.href);
if (consumed.token) {
token = consumed.token;
tokenFromLink = true;
}
hydrated = true;
if (!fromHash && !fromQuery && !fromSsrQuery) return;
const cleaned = new URL(window.location.href);
cleaned.searchParams.delete("token");
cleaned.hash = "";
history.replaceState(history.state, "", cleaned.pathname + cleaned.search);
if (consumed.stripped) {
history.replaceState(history.state, "", consumed.cleanedPathSearch);
}
});
async function onSubmit(event: Event) {
+1 -1
View File
@@ -598,7 +598,7 @@
const link =
created.accept_url?.trim() ||
(created.token
? `${window.location.origin}/accept-invite?token=${encodeURIComponent(created.token)}`
? `${window.location.origin}/accept-invite#token=${encodeURIComponent(created.token)}`
: "");
trackEvent("invite_sent", {
role: roleForEvent,
+11 -1
View File
@@ -7,6 +7,7 @@
import { Button } from "$lib/components/ui";
import SiteHeader from "$lib/components/site/SiteHeader.svelte";
import Footer from "$lib/components/site/Footer.svelte";
import SeoHead from "$lib/components/site/SeoHead.svelte";
const API_BASE = (PUBLIC_API_URL ?? "").replace(/\/$/, "") || "";
@@ -16,11 +17,12 @@
let message = $state("");
let error = $state("");
let already = $state(false);
let missingToken = $state(false);
onMount(async () => {
token = page.url.searchParams.get("token") ?? "";
if (!token) {
error = i18n.t("flash.unsubscribe.missingToken");
missingToken = true;
loading = false;
return;
}
@@ -64,6 +66,12 @@
}
</script>
<SeoHead
title={i18n.t("seo.unsubscribe.title")}
description={i18n.t("seo.unsubscribe.description")}
path="/unsubscribe"
/>
<div class="flex min-h-screen flex-col bg-background">
<SiteHeader />
<main
@@ -73,6 +81,8 @@
<h1 class="text-2xl font-semibold text-text">{i18n.t("unsubscribe.title")}</h1>
{#if loading}
<p class="text-sm text-text-muted">{i18n.t("common.loading")}</p>
{:else if missingToken}
<p class="text-sm text-text-muted">{i18n.t("unsubscribe.useEmailLink")}</p>
{:else if error}
<p class="text-sm text-danger">{error}</p>
{:else}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

@@ -1,72 +0,0 @@
{
"action_log": [
"step 1 wait_hydrate — ok",
"step 2 fill \"#email,input[type=email]\"=\"demo@descrybe.local\" — ok",
"step 3 fill \"#password,input[type=password]\"=\"DemoPass123!\" — ok",
"step 4 click button[type=submit] — ok",
"step 5 wait_nav — ok (http://localhost:28472/login)",
"step 6 wait_hydrate — ok",
"step 7 wait 1500ms — ok"
],
"actions_failed": false,
"console": [
{
"level": "debug",
"text": "[vite] connecting..."
},
{
"level": "debug",
"text": "[vite] connected."
}
],
"console_errors": [],
"debug_pack_dir": "",
"debug_pack_json": "",
"device": "mobile",
"doc_status": 200,
"failed_requests": [
{
"url": "http://localhost:28471/api/auth/me",
"status": 401
}
],
"failure_pack": {
"failed": false,
"final_url": "http://localhost:28472/dashboard",
"title": "Descrybe",
"doc_status": 200,
"device": "mobile",
"viewport": "390x844@2x",
"screenshot_path": "F:\\laragon\\www\\_MY\\descrybe-v2\\apps\\web\\static\\.audit\\00-post-login-mobile.png",
"report_path": "F:\\laragon\\www\\_MY\\descrybe-v2\\apps\\web\\static\\.audit\\00-post-login-mobile.report.json",
"console_errors": [],
"page_errors": null,
"failed_requests": [
{
"url": "http://localhost:28471/api/auth/me",
"status": 401
}
],
"action_log": [
"step 1 wait_hydrate — ok",
"step 2 fill \"#email,input[type=email]\"=\"demo@descrybe.local\" — ok",
"step 3 fill \"#password,input[type=password]\"=\"DemoPass123!\" — ok",
"step 4 click button[type=submit] — ok",
"step 5 wait_nav — ok (http://localhost:28472/login)",
"step 6 wait_hydrate — ok",
"step 7 wait 1500ms — ok"
],
"generated_at": "2026-08-08T23:04:24Z"
},
"final_url": "http://localhost:28472/dashboard",
"format": "png",
"generated_at": "2026-08-08T23:04:24Z",
"load_ms": 1210,
"outline": null,
"page_errors": null,
"screenshot_path": "F:\\laragon\\www\\_MY\\descrybe-v2\\apps\\web\\static\\.audit\\00-post-login-mobile.png",
"snapshot": "",
"title": "Descrybe",
"trace": null,
"viewport": "390x844@2x"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

@@ -1,72 +0,0 @@
{
"action_log": [
"step 1 wait_hydrate — ok",
"step 2 fill \"#email,input[type=email]\"=\"demo@descrybe.local\" — ok",
"step 3 fill \"#password,input[type=password]\"=\"DemoPass123!\" — ok",
"step 4 click button[type=submit] — ok",
"step 5 wait_nav — ok (http://localhost:28472/login)",
"step 6 wait_hydrate — ok",
"step 7 wait 1500ms — ok"
],
"actions_failed": false,
"console": [
{
"level": "debug",
"text": "[vite] connecting..."
},
{
"level": "debug",
"text": "[vite] connected."
}
],
"console_errors": [],
"debug_pack_dir": "",
"debug_pack_json": "",
"device": "tablet",
"doc_status": 200,
"failed_requests": [
{
"url": "http://localhost:28471/api/auth/me",
"status": 401
}
],
"failure_pack": {
"failed": false,
"final_url": "http://localhost:28472/dashboard",
"title": "Descrybe",
"doc_status": 200,
"device": "tablet",
"viewport": "768x1024@2x",
"screenshot_path": "F:\\laragon\\www\\_MY\\descrybe-v2\\apps\\web\\static\\.audit\\00-post-login-tablet.png",
"report_path": "F:\\laragon\\www\\_MY\\descrybe-v2\\apps\\web\\static\\.audit\\00-post-login-tablet.report.json",
"console_errors": [],
"page_errors": null,
"failed_requests": [
{
"url": "http://localhost:28471/api/auth/me",
"status": 401
}
],
"action_log": [
"step 1 wait_hydrate — ok",
"step 2 fill \"#email,input[type=email]\"=\"demo@descrybe.local\" — ok",
"step 3 fill \"#password,input[type=password]\"=\"DemoPass123!\" — ok",
"step 4 click button[type=submit] — ok",
"step 5 wait_nav — ok (http://localhost:28472/login)",
"step 6 wait_hydrate — ok",
"step 7 wait 1500ms — ok"
],
"generated_at": "2026-08-08T23:08:42Z"
},
"final_url": "http://localhost:28472/dashboard",
"format": "png",
"generated_at": "2026-08-08T23:08:42Z",
"load_ms": 4242,
"outline": null,
"page_errors": null,
"screenshot_path": "F:\\laragon\\www\\_MY\\descrybe-v2\\apps\\web\\static\\.audit\\00-post-login-tablet.png",
"snapshot": "",
"title": "Descrybe",
"trace": null,
"viewport": "768x1024@2x"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

@@ -1,72 +0,0 @@
{
"action_log": [
"step 1 wait_hydrate — ok",
"step 2 fill \"#email,input[type=email]\"=\"demo@descrybe.local\" — ok",
"step 3 fill \"#password,input[type=password]\"=\"DemoPass123!\" — ok",
"step 4 click button[type=submit] — ok",
"step 5 wait_nav — ok (http://localhost:28472/login)",
"step 6 wait_hydrate — ok",
"step 7 wait 1200ms — ok"
],
"actions_failed": false,
"console": [
{
"level": "debug",
"text": "[vite] connecting..."
},
{
"level": "debug",
"text": "[vite] connected."
}
],
"console_errors": [],
"debug_pack_dir": "",
"debug_pack_json": "",
"device": "tablet",
"doc_status": 200,
"failed_requests": [
{
"url": "http://localhost:28471/api/auth/me",
"status": 401
}
],
"failure_pack": {
"failed": false,
"final_url": "http://localhost:28472/dashboard",
"title": "Descrybe",
"doc_status": 200,
"device": "tablet",
"viewport": "768x1024@2x",
"screenshot_path": "F:\\laragon\\www\\_MY\\descrybe-v2\\apps\\web\\static\\.audit\\00b-login-tablet.png",
"report_path": "F:\\laragon\\www\\_MY\\descrybe-v2\\apps\\web\\static\\.audit\\00b-login-tablet.report.json",
"console_errors": [],
"page_errors": null,
"failed_requests": [
{
"url": "http://localhost:28471/api/auth/me",
"status": 401
}
],
"action_log": [
"step 1 wait_hydrate — ok",
"step 2 fill \"#email,input[type=email]\"=\"demo@descrybe.local\" — ok",
"step 3 fill \"#password,input[type=password]\"=\"DemoPass123!\" — ok",
"step 4 click button[type=submit] — ok",
"step 5 wait_nav — ok (http://localhost:28472/login)",
"step 6 wait_hydrate — ok",
"step 7 wait 1200ms — ok"
],
"generated_at": "2026-08-09T08:05:56Z"
},
"final_url": "http://localhost:28472/dashboard",
"format": "png",
"generated_at": "2026-08-09T08:05:56Z",
"load_ms": 16697,
"outline": null,
"page_errors": null,
"screenshot_path": "F:\\laragon\\www\\_MY\\descrybe-v2\\apps\\web\\static\\.audit\\00b-login-tablet.png",
"snapshot": "",
"title": "Descrybe",
"trace": null,
"viewport": "768x1024@2x"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

@@ -1,444 +0,0 @@
{
"action_log": [
"step 1 wait_hydrate — ok",
"step 2 fill \"input[type=email],input[name=email],#email\"=\"demo@descrybe.local\" — ok",
"step 3 fill \"input[type=password],input[name=password],#password\"=\"DemoPass123!\" — ok",
"step 4 click button[type=submit] — ok",
"step 5 wait_nav — ok (http://localhost:28472/login)",
"step 6 wait_hydrate — ok",
"step 7 wait 2000ms — ok"
],
"actions_failed": false,
"console": [
{
"level": "debug",
"text": "[vite] connecting..."
},
{
"level": "debug",
"text": "[vite] connected."
}
],
"console_errors": [],
"debug_pack_dir": "",
"debug_pack_json": "",
"device": "mobile",
"doc_status": 200,
"failed_requests": [
{
"url": "http://localhost:28471/api/auth/me",
"status": 401
}
],
"failure_pack": {
"failed": false,
"final_url": "http://localhost:28472/dashboard",
"title": "Descrybe",
"doc_status": 200,
"device": "mobile",
"viewport": "390x844@2x",
"screenshot_path": "F:\\laragon\\www\\_MY\\descrybe-v2\\apps\\web\\static\\.audit\\01-dashboard-mobile.png",
"report_path": "F:\\laragon\\www\\_MY\\descrybe-v2\\apps\\web\\static\\.audit\\01-dashboard-mobile.report.json",
"console_errors": [],
"page_errors": null,
"failed_requests": [
{
"url": "http://localhost:28471/api/auth/me",
"status": 401
}
],
"outline": [
{
"ref": "e1",
"selector": "#app-shell \u003e a",
"role": "link",
"name": "Skip to content"
},
{
"ref": "e2",
"selector": "button[aria-label=\"Open navigation\"]",
"role": "button",
"name": "Open navigation",
"type": "button"
},
{
"ref": "e3",
"selector": "button[aria-label=\"Switch user: Demo User · Platform Demo\"]",
"role": "button",
"name": "Switch user: Demo User · Platform Demo",
"type": "button"
},
{
"ref": "e4",
"selector": "a[aria-label=\"Support notifications, 1 unread\"]",
"role": "link",
"name": "Support notifications, 1 unread"
},
{
"ref": "e5",
"selector": "button[aria-label=\"Sign out\"]",
"role": "button",
"name": "Sign out",
"type": "button"
},
{
"ref": "e6",
"selector": "div \u003e div \u003e header \u003e div:nth-of-type(2) \u003e button:nth-of-type(1)",
"role": "button",
"name": "Start tutorial",
"type": "button"
},
{
"ref": "e7",
"selector": "div \u003e div \u003e header \u003e div:nth-of-type(2) \u003e button:nth-of-type(2)",
"role": "button",
"name": "Open products",
"type": "button"
},
{
"ref": "e8",
"selector": "a[data-testid=\"store-reconnect-link-woocommerce\"]",
"role": "link",
"name": "WooCommerce"
},
{
"ref": "e9",
"selector": "a[data-testid=\"store-reconnect-cta-woocommerce\"]",
"role": "link",
"name": "Reconnect WooCommerce"
},
{
"ref": "e10",
"selector": "div:nth-of-type(1) \u003e div \u003e div:nth-of-type(2) \u003e a \u003e button",
"role": "button",
"name": "Reconnect WooCommerce",
"type": "button"
},
{
"ref": "e11",
"selector": "div \u003e section:nth-of-type(1) \u003e ol \u003e li:nth-of-type(1) \u003e a",
"role": "link",
"name": "1. Feeds Connect import"
},
{
"ref": "e12",
"selector": "div \u003e section:nth-of-type(1) \u003e ol \u003e li:nth-of-type(2) \u003e a",
"role": "link",
"name": "2. Products 5 in catalog"
},
{
"ref": "e13",
"selector": "div \u003e section:nth-of-type(1) \u003e ol \u003e li:nth-of-type(3) \u003e a",
"role": "link",
"name": "3. Process 8 done"
},
{
"ref": "e14",
"selector": "div \u003e section:nth-of-type(1) \u003e ol \u003e li:nth-of-type(4) \u003e a",
"role": "link",
"name": "4. Export Templates \u0026 download"
},
{
"ref": "e15",
"selector": "div \u003e div \u003e section:nth-of-type(2) \u003e div:nth-of-type(2) \u003e a:nth-of-type(1)",
"role": "link",
"name": "Products 5 8 processed · 0 unprocessed"
},
{
"ref": "e16",
"selector": "div \u003e div \u003e section:nth-of-type(2) \u003e div:nth-of-type(2) \u003e a:nth-of-type(2)",
"role": "link",
"name": "Categories 2 Catalog tree"
},
{
"ref": "e17",
"selector": "div \u003e div \u003e section:nth-of-type(2) \u003e div:nth-of-type(2) \u003e a:nth-of-type(3)",
"role": "link",
"name": "Attributes 1 Attribute library"
},
{
"ref": "e18",
"selector": "div \u003e div \u003e section:nth-of-type(2) \u003e div:nth-of-type(2) \u003e a:nth-of-type(4)",
"role": "link",
"name": "Feeds 0 Import sources"
},
{
"ref": "e19",
"selector": "div \u003e div \u003e section:nth-of-type(2) \u003e div:nth-of-type(2) \u003e a:nth-of-type(5)",
"role": "link",
"name": "Credits Unlimited Enterprise · Billing"
},
{
"ref": "e20",
"selector": "div \u003e div:nth-of-type(2) \u003e section:nth-of-type(1) \u003e div:nth-of-type(1) \u003e a",
"role": "link",
"name": "View all"
},
{
"ref": "e21",
"selector": "div \u003e div:nth-of-type(2) \u003e section:nth-of-type(2) \u003e div:nth-of-type(2) \u003e a:nth-of-type(1)",
"role": "link",
"name": "Feeds Import and map"
},
{
"ref": "e22",
"selector": "div \u003e div:nth-of-type(2) \u003e section:nth-of-type(2) \u003e div:nth-of-type(2) \u003e a:nth-of-type(2)",
"role": "link",
"name": "Products Browse and process"
},
{
"ref": "e23",
"selector": "div \u003e div:nth-of-type(2) \u003e section:nth-of-type(2) \u003e div:nth-of-type(2) \u003e a:nth-of-type(3)",
"role": "link",
"name": "Jobs Monitor tasks"
},
{
"ref": "e24",
"selector": "div \u003e div:nth-of-type(2) \u003e section:nth-of-type(2) \u003e div:nth-of-type(2) \u003e a:nth-of-type(4)",
"role": "link",
"name": "Exports Templates \u0026 download"
},
{
"ref": "e25",
"selector": "#cookie-consent-desc \u003e a",
"role": "link",
"name": "Read our Cookie Policy"
},
{
"ref": "e26",
"selector": "div \u003e div:nth-of-type(2) \u003e div \u003e div:nth-of-type(2) \u003e button:nth-of-type(1)",
"role": "button",
"name": "Customize",
"type": "button"
},
{
"ref": "e27",
"selector": "div \u003e div:nth-of-type(2) \u003e div \u003e div:nth-of-type(2) \u003e button:nth-of-type(2)",
"role": "button",
"name": "Reject non-essential",
"type": "button"
},
{
"ref": "e28",
"selector": "div \u003e div:nth-of-type(2) \u003e div \u003e div:nth-of-type(2) \u003e button:nth-of-type(3)",
"role": "button",
"name": "Accept all",
"type": "button"
},
{
"ref": "e29",
"selector": "button[aria-label=\"Open system assistant\"]",
"role": "button",
"name": "Open system assistant",
"type": "button"
}
],
"snapshot": "link \"Skip to content\"\nmain \"Platform Demo 1 PLATFORM DEMO Platform Demo Unlimited Start \"\nbanner \"Platform Demo 1\"\nbutton \"Open navigation\"\nbutton \"Switch user: Demo User · Platform Demo\"\nlink \"Support notifications, 1 unread\"\nbutton \"Sign out\"\nbanner \"PLATFORM DEMO Platform Demo Unlimited Start tutorial Open pr\"\nheading \"Platform Demo\" [level=1]\nbutton \"Start tutorial\"\nbutton \"Open products\"\nstatus \"Reconnect store after migration Store API credentials were n\" [testid=store-reconnect-banner]\nlink \"WooCommerce\" [testid=store-reconnect-link-woocommerce]\nlink \"Reconnect WooCommerce\" [testid=store-reconnect-cta-woocommerce]\nbutton \"Reconnect WooCommerce\"\nheading \"Catalog workflow\" [level=2]\nlink \"1. Feeds Connect import\"\nlink \"2. Products 5 in catalog\"\nlink \"3. Process 8 done\"\nlink \"4. Export Templates \u0026 download\"\nheading \"Overview\" [level=2]\nlink \"Products 5 8 processed · 0 unprocessed\"\nlink \"Categories 2 Catalog tree\"\nlink \"Attributes 1 Attribute library\"\nlink \"Feeds 0 Import sources\"\nlink \"Credits Unlimited Enterprise · Billing\"\nheading \"Recent activity\" [level=2]\nlink \"View all\"\nheading \"Quick links\" [level=2]\nlink \"Feeds Import and map\"\nlink \"Products Browse and process\"\nlink \"Jobs Monitor tasks\"\nlink \"Exports Templates \u0026 download\"\nheading \"WHAT'S NEW\" [level=3]\nheading \"User Experience Improvements\" [level=3]\nheading \"Key Features:\" [level=4]\ndialog \"Cookies and analytics We use necessary cookies to run Descry\" [testid=cookie-consent-banner]\nheading \"Cookies and analytics\" [level=2]\nlink \"Read our Cookie Policy\"\nbutton \"Customize\"\nbutton \"Reject non-essential\"\nbutton \"Accept all\"\nbutton \"Open system assistant\"",
"action_log": [
"step 1 wait_hydrate — ok",
"step 2 fill \"input[type=email],input[name=email],#email\"=\"demo@descrybe.local\" — ok",
"step 3 fill \"input[type=password],input[name=password],#password\"=\"DemoPass123!\" — ok",
"step 4 click button[type=submit] — ok",
"step 5 wait_nav — ok (http://localhost:28472/login)",
"step 6 wait_hydrate — ok",
"step 7 wait 2000ms — ok"
],
"generated_at": "2026-08-08T23:03:35Z"
},
"final_url": "http://localhost:28472/dashboard",
"format": "png",
"generated_at": "2026-08-08T23:03:35Z",
"load_ms": 1357,
"outline": [
{
"ref": "e1",
"selector": "#app-shell \u003e a",
"role": "link",
"name": "Skip to content"
},
{
"ref": "e2",
"selector": "button[aria-label=\"Open navigation\"]",
"role": "button",
"name": "Open navigation",
"type": "button"
},
{
"ref": "e3",
"selector": "button[aria-label=\"Switch user: Demo User · Platform Demo\"]",
"role": "button",
"name": "Switch user: Demo User · Platform Demo",
"type": "button"
},
{
"ref": "e4",
"selector": "a[aria-label=\"Support notifications, 1 unread\"]",
"role": "link",
"name": "Support notifications, 1 unread"
},
{
"ref": "e5",
"selector": "button[aria-label=\"Sign out\"]",
"role": "button",
"name": "Sign out",
"type": "button"
},
{
"ref": "e6",
"selector": "div \u003e div \u003e header \u003e div:nth-of-type(2) \u003e button:nth-of-type(1)",
"role": "button",
"name": "Start tutorial",
"type": "button"
},
{
"ref": "e7",
"selector": "div \u003e div \u003e header \u003e div:nth-of-type(2) \u003e button:nth-of-type(2)",
"role": "button",
"name": "Open products",
"type": "button"
},
{
"ref": "e8",
"selector": "a[data-testid=\"store-reconnect-link-woocommerce\"]",
"role": "link",
"name": "WooCommerce"
},
{
"ref": "e9",
"selector": "a[data-testid=\"store-reconnect-cta-woocommerce\"]",
"role": "link",
"name": "Reconnect WooCommerce"
},
{
"ref": "e10",
"selector": "div:nth-of-type(1) \u003e div \u003e div:nth-of-type(2) \u003e a \u003e button",
"role": "button",
"name": "Reconnect WooCommerce",
"type": "button"
},
{
"ref": "e11",
"selector": "div \u003e section:nth-of-type(1) \u003e ol \u003e li:nth-of-type(1) \u003e a",
"role": "link",
"name": "1. Feeds Connect import"
},
{
"ref": "e12",
"selector": "div \u003e section:nth-of-type(1) \u003e ol \u003e li:nth-of-type(2) \u003e a",
"role": "link",
"name": "2. Products 5 in catalog"
},
{
"ref": "e13",
"selector": "div \u003e section:nth-of-type(1) \u003e ol \u003e li:nth-of-type(3) \u003e a",
"role": "link",
"name": "3. Process 8 done"
},
{
"ref": "e14",
"selector": "div \u003e section:nth-of-type(1) \u003e ol \u003e li:nth-of-type(4) \u003e a",
"role": "link",
"name": "4. Export Templates \u0026 download"
},
{
"ref": "e15",
"selector": "div \u003e div \u003e section:nth-of-type(2) \u003e div:nth-of-type(2) \u003e a:nth-of-type(1)",
"role": "link",
"name": "Products 5 8 processed · 0 unprocessed"
},
{
"ref": "e16",
"selector": "div \u003e div \u003e section:nth-of-type(2) \u003e div:nth-of-type(2) \u003e a:nth-of-type(2)",
"role": "link",
"name": "Categories 2 Catalog tree"
},
{
"ref": "e17",
"selector": "div \u003e div \u003e section:nth-of-type(2) \u003e div:nth-of-type(2) \u003e a:nth-of-type(3)",
"role": "link",
"name": "Attributes 1 Attribute library"
},
{
"ref": "e18",
"selector": "div \u003e div \u003e section:nth-of-type(2) \u003e div:nth-of-type(2) \u003e a:nth-of-type(4)",
"role": "link",
"name": "Feeds 0 Import sources"
},
{
"ref": "e19",
"selector": "div \u003e div \u003e section:nth-of-type(2) \u003e div:nth-of-type(2) \u003e a:nth-of-type(5)",
"role": "link",
"name": "Credits Unlimited Enterprise · Billing"
},
{
"ref": "e20",
"selector": "div \u003e div:nth-of-type(2) \u003e section:nth-of-type(1) \u003e div:nth-of-type(1) \u003e a",
"role": "link",
"name": "View all"
},
{
"ref": "e21",
"selector": "div \u003e div:nth-of-type(2) \u003e section:nth-of-type(2) \u003e div:nth-of-type(2) \u003e a:nth-of-type(1)",
"role": "link",
"name": "Feeds Import and map"
},
{
"ref": "e22",
"selector": "div \u003e div:nth-of-type(2) \u003e section:nth-of-type(2) \u003e div:nth-of-type(2) \u003e a:nth-of-type(2)",
"role": "link",
"name": "Products Browse and process"
},
{
"ref": "e23",
"selector": "div \u003e div:nth-of-type(2) \u003e section:nth-of-type(2) \u003e div:nth-of-type(2) \u003e a:nth-of-type(3)",
"role": "link",
"name": "Jobs Monitor tasks"
},
{
"ref": "e24",
"selector": "div \u003e div:nth-of-type(2) \u003e section:nth-of-type(2) \u003e div:nth-of-type(2) \u003e a:nth-of-type(4)",
"role": "link",
"name": "Exports Templates \u0026 download"
},
{
"ref": "e25",
"selector": "#cookie-consent-desc \u003e a",
"role": "link",
"name": "Read our Cookie Policy"
},
{
"ref": "e26",
"selector": "div \u003e div:nth-of-type(2) \u003e div \u003e div:nth-of-type(2) \u003e button:nth-of-type(1)",
"role": "button",
"name": "Customize",
"type": "button"
},
{
"ref": "e27",
"selector": "div \u003e div:nth-of-type(2) \u003e div \u003e div:nth-of-type(2) \u003e button:nth-of-type(2)",
"role": "button",
"name": "Reject non-essential",
"type": "button"
},
{
"ref": "e28",
"selector": "div \u003e div:nth-of-type(2) \u003e div \u003e div:nth-of-type(2) \u003e button:nth-of-type(3)",
"role": "button",
"name": "Accept all",
"type": "button"
},
{
"ref": "e29",
"selector": "button[aria-label=\"Open system assistant\"]",
"role": "button",
"name": "Open system assistant",
"type": "button"
}
],
"page_errors": null,
"screenshot_path": "F:\\laragon\\www\\_MY\\descrybe-v2\\apps\\web\\static\\.audit\\01-dashboard-mobile.png",
"snapshot": "link \"Skip to content\"\nmain \"Platform Demo 1 PLATFORM DEMO Platform Demo Unlimited Start \"\nbanner \"Platform Demo 1\"\nbutton \"Open navigation\"\nbutton \"Switch user: Demo User · Platform Demo\"\nlink \"Support notifications, 1 unread\"\nbutton \"Sign out\"\nbanner \"PLATFORM DEMO Platform Demo Unlimited Start tutorial Open pr\"\nheading \"Platform Demo\" [level=1]\nbutton \"Start tutorial\"\nbutton \"Open products\"\nstatus \"Reconnect store after migration Store API credentials were n\" [testid=store-reconnect-banner]\nlink \"WooCommerce\" [testid=store-reconnect-link-woocommerce]\nlink \"Reconnect WooCommerce\" [testid=store-reconnect-cta-woocommerce]\nbutton \"Reconnect WooCommerce\"\nheading \"Catalog workflow\" [level=2]\nlink \"1. Feeds Connect import\"\nlink \"2. Products 5 in catalog\"\nlink \"3. Process 8 done\"\nlink \"4. Export Templates \u0026 download\"\nheading \"Overview\" [level=2]\nlink \"Products 5 8 processed · 0 unprocessed\"\nlink \"Categories 2 Catalog tree\"\nlink \"Attributes 1 Attribute library\"\nlink \"Feeds 0 Import sources\"\nlink \"Credits Unlimited Enterprise · Billing\"\nheading \"Recent activity\" [level=2]\nlink \"View all\"\nheading \"Quick links\" [level=2]\nlink \"Feeds Import and map\"\nlink \"Products Browse and process\"\nlink \"Jobs Monitor tasks\"\nlink \"Exports Templates \u0026 download\"\nheading \"WHAT'S NEW\" [level=3]\nheading \"User Experience Improvements\" [level=3]\nheading \"Key Features:\" [level=4]\ndialog \"Cookies and analytics We use necessary cookies to run Descry\" [testid=cookie-consent-banner]\nheading \"Cookies and analytics\" [level=2]\nlink \"Read our Cookie Policy\"\nbutton \"Customize\"\nbutton \"Reject non-essential\"\nbutton \"Accept all\"\nbutton \"Open system assistant\"",
"title": "Descrybe",
"trace": null,
"viewport": "390x844@2x"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

@@ -1,46 +0,0 @@
{
"action_log": null,
"actions_failed": false,
"console": [
{
"level": "debug",
"text": "[vite] connecting..."
},
{
"level": "debug",
"text": "[vite] connected."
}
],
"console_errors": [],
"debug_pack_dir": "",
"debug_pack_json": "",
"device": "mobile",
"doc_status": 200,
"failed_requests": null,
"failure_pack": {
"failed": false,
"final_url": "http://localhost:28472/login",
"title": "Descrybe",
"doc_status": 200,
"device": "mobile",
"viewport": "390x844@2x",
"screenshot_path": "F:\\laragon\\www\\_MY\\descrybe-v2\\apps\\web\\static\\.audit\\02-login-mobile.png",
"report_path": "F:\\laragon\\www\\_MY\\descrybe-v2\\apps\\web\\static\\.audit\\02-login-mobile.report.json",
"console_errors": [],
"page_errors": null,
"failed_requests": null,
"action_log": null,
"generated_at": "2026-08-08T23:15:01Z"
},
"final_url": "http://localhost:28472/login",
"format": "png",
"generated_at": "2026-08-08T23:15:01Z",
"load_ms": 969,
"outline": null,
"page_errors": null,
"screenshot_path": "F:\\laragon\\www\\_MY\\descrybe-v2\\apps\\web\\static\\.audit\\02-login-mobile.png",
"snapshot": "",
"title": "Descrybe",
"trace": null,
"viewport": "390x844@2x"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

@@ -1,46 +0,0 @@
{
"action_log": null,
"actions_failed": false,
"console": [
{
"level": "debug",
"text": "[vite] connecting..."
},
{
"level": "debug",
"text": "[vite] connected."
}
],
"console_errors": [],
"debug_pack_dir": "",
"debug_pack_json": "",
"device": "tablet",
"doc_status": 200,
"failed_requests": null,
"failure_pack": {
"failed": false,
"final_url": "http://localhost:28472/login",
"title": "Descrybe",
"doc_status": 200,
"device": "tablet",
"viewport": "768x1024@2x",
"screenshot_path": "F:\\laragon\\www\\_MY\\descrybe-v2\\apps\\web\\static\\.audit\\02-login-tablet.png",
"report_path": "F:\\laragon\\www\\_MY\\descrybe-v2\\apps\\web\\static\\.audit\\02-login-tablet.report.json",
"console_errors": [],
"page_errors": null,
"failed_requests": null,
"action_log": null,
"generated_at": "2026-08-08T23:15:03Z"
},
"final_url": "http://localhost:28472/login",
"format": "png",
"generated_at": "2026-08-08T23:15:03Z",
"load_ms": 986,
"outline": null,
"page_errors": null,
"screenshot_path": "F:\\laragon\\www\\_MY\\descrybe-v2\\apps\\web\\static\\.audit\\02-login-tablet.png",
"snapshot": "",
"title": "Descrybe",
"trace": null,
"viewport": "768x1024@2x"
}

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