fix
This commit is contained in:
@@ -58,3 +58,9 @@ scripts/_layout_snip.js
|
|||||||
scripts/write_001.py
|
scripts/write_001.py
|
||||||
apps/web/scripts/_*
|
apps/web/scripts/_*
|
||||||
apps/web/scripts/tr-*
|
apps/web/scripts/tr-*
|
||||||
|
|
||||||
|
# Never ship local browser-audit dumps as public static
|
||||||
|
apps/web/static/.audit/
|
||||||
|
|
||||||
|
# codehelper (generated local — do not commit)
|
||||||
|
.zed/
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||||
|
"github.com/descrybe/descrybe-v2/apps/api/internal/mail"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
@@ -37,7 +37,7 @@ func webOrigin() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func setPasswordInviteURL(token string) 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.
|
// prepareSetPasswordHooks creates invites for active users with must_set_password=true.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
func TestSetPasswordInviteURL(t *testing.T) {
|
func TestSetPasswordInviteURL(t *testing.T) {
|
||||||
t.Setenv("WEB_ORIGIN", "https://app.example.com/")
|
t.Setenv("WEB_ORIGIN", "https://app.example.com/")
|
||||||
got := setPasswordInviteURL("tok+1")
|
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) {
|
if !strings.HasPrefix(got, wantPrefix) {
|
||||||
t.Fatalf("got %q", got)
|
t.Fatalf("got %q", got)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ func ClientError(err error) (msg string, ok bool) {
|
|||||||
switch {
|
switch {
|
||||||
case err == nil:
|
case err == nil:
|
||||||
return "", false
|
return "", false
|
||||||
|
case errors.Is(err, ErrSyntheticEmail):
|
||||||
|
return "this email cannot receive invites", true
|
||||||
case errors.Is(err, ErrRegisterFieldsRequired),
|
case errors.Is(err, ErrRegisterFieldsRequired),
|
||||||
errors.Is(err, ErrPasswordTooShort),
|
errors.Is(err, ErrPasswordTooShort),
|
||||||
errors.Is(err, ErrPasswordAlreadySet),
|
errors.Is(err, ErrPasswordAlreadySet),
|
||||||
@@ -32,7 +34,6 @@ func ClientError(err error) (msg string, ok bool) {
|
|||||||
errors.Is(err, ErrInviteNotFound),
|
errors.Is(err, ErrInviteNotFound),
|
||||||
errors.Is(err, ErrTokenInvalid),
|
errors.Is(err, ErrTokenInvalid),
|
||||||
errors.Is(err, ErrEmailRequired),
|
errors.Is(err, ErrEmailRequired),
|
||||||
errors.Is(err, ErrSyntheticEmail),
|
|
||||||
errors.Is(err, ErrNotEligibleSetPassword),
|
errors.Is(err, ErrNotEligibleSetPassword),
|
||||||
errors.Is(err, ErrEmailMismatch),
|
errors.Is(err, ErrEmailMismatch),
|
||||||
errors.Is(err, ErrNotCompanyOwner),
|
errors.Is(err, ErrNotCompanyOwner),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1265,7 +1265,7 @@ func (s *Service) GetProcessedProduct(ctx context.Context, companyID, id uuid.UU
|
|||||||
item["content_language"] = primary
|
item["content_language"] = primary
|
||||||
item["content_languages"] = company.LoadContentLanguages(ctx, s.Pool, companyID)
|
item["content_languages"] = company.LoadContentLanguages(ctx, s.Pool, companyID)
|
||||||
if loc, err := company.DecodeLocalizedContent(item["localized_content"]); err == nil {
|
if loc, err := company.DecodeLocalizedContent(item["localized_content"]); err == nil {
|
||||||
item["localized_content"] = loc
|
item["localized_content"] = loc.Public()
|
||||||
}
|
}
|
||||||
return item, nil
|
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_language"] = primary
|
||||||
item["content_languages"] = company.LoadContentLanguages(ctx, s.Pool, companyID)
|
item["content_languages"] = company.LoadContentLanguages(ctx, s.Pool, companyID)
|
||||||
if loc, err := company.DecodeLocalizedContent(item["localized_content"]); err == nil {
|
if loc, err := company.DecodeLocalizedContent(item["localized_content"]); err == nil {
|
||||||
item["localized_content"] = loc
|
item["localized_content"] = loc.Public()
|
||||||
}
|
}
|
||||||
return item, nil
|
return item, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,21 @@ type LocalizedFields struct {
|
|||||||
// LocalizedContent is language-code → per-language product output fields.
|
// LocalizedContent is language-code → per-language product output fields.
|
||||||
type LocalizedContent map[string]LocalizedFields
|
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.
|
// SanitizeLangPromptMap validates language codes, sanitizes prompts, and drops empties.
|
||||||
// Accepts LangPromptAny ("*") as a shared any-language prompt key.
|
// Accepts LangPromptAny ("*") as a shared any-language prompt key.
|
||||||
func SanitizeLangPromptMap(in map[string]string, maxRunes int) (LangPromptMap, error) {
|
func SanitizeLangPromptMap(in map[string]string, maxRunes int) (LangPromptMap, error) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package company
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -101,3 +102,32 @@ func TestLocalizedContentRoundTrip(t *testing.T) {
|
|||||||
t.Fatalf("got %#v", f)
|
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) {
|
func TestAcceptInviteURL(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
got := mail.AcceptInviteURL("http://localhost:28472/", "abc123")
|
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 {
|
if got != want {
|
||||||
t.Fatalf("AcceptInviteURL = %q want %q", 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/catalog"
|
||||||
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
|
"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/go-chi/chi/v5"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@@ -403,6 +404,7 @@ func (s *Server) handleListProducts(w http.ResponseWriter, r *http.Request) {
|
|||||||
if detailed {
|
if detailed {
|
||||||
attachProductQuality(items)
|
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}
|
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, nextAfter := catalog.NextProductCursor(f, items, limit); nextCursor != "" || nextAfter != "" {
|
||||||
if nextCursor != "" {
|
if nextCursor != "" {
|
||||||
@@ -438,6 +440,7 @@ func (s *Server) handleGetProduct(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
stripCatalogSEOMetaIfOmitted(processing.CompanyOmitsSEOMeta(r.Context(), s.Pool, cid), item)
|
||||||
JSON(w, http.StatusOK, 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)
|
ClientOrLog(w, http.StatusBadRequest, "could not update product", err, catalog.ClientError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
stripCatalogSEOMetaIfOmitted(processing.CompanyOmitsSEOMeta(r.Context(), s.Pool, cid), item)
|
||||||
JSON(w, http.StatusOK, 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 {
|
func presentV1Attribute(item map[string]any) map[string]any {
|
||||||
out := map[string]any{
|
out := map[string]any{
|
||||||
"id": item["id"],
|
"id": item["id"],
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ func TestPresentV1CategoryAndAttribute(t *testing.T) {
|
|||||||
cat := presentV1Category(map[string]any{
|
cat := presentV1Category(map[string]any{
|
||||||
"id": id, "unique_id": "electronics", "name": "Electronics",
|
"id": id, "unique_id": "electronics", "name": "Electronics",
|
||||||
"created_at": ts, "updated_at": ts,
|
"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" {
|
if cat["unique_id"] != "electronics" || cat["name"] != "Electronics" {
|
||||||
t.Fatalf("category=%v", cat)
|
t.Fatalf("category=%v", cat)
|
||||||
@@ -24,6 +27,11 @@ func TestPresentV1CategoryAndAttribute(t *testing.T) {
|
|||||||
if cat["created_at"] != "2026-07-01T08:00:00Z" {
|
if cat["created_at"] != "2026-07-01T08:00:00Z" {
|
||||||
t.Fatalf("created_at=%v", cat["created_at"])
|
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{
|
attr := presentV1Attribute(map[string]any{
|
||||||
"id": id, "attribute_key": "color", "name": "Color", "value_type": "string",
|
"id": id, "attribute_key": "color", "name": "Color", "value_type": "string",
|
||||||
@@ -41,11 +49,11 @@ func TestPresentV1CategoryAndAttribute(t *testing.T) {
|
|||||||
func TestV1OpenAPICategoriesAttributesLegacyContract(t *testing.T) {
|
func TestV1OpenAPICategoriesAttributesLegacyContract(t *testing.T) {
|
||||||
body := string(v1OpenAPIYAML)
|
body := string(v1OpenAPIYAML)
|
||||||
needles := []string{
|
needles := []string{
|
||||||
"LegacyCategoriesResponse",
|
"CategoriesEnvelope",
|
||||||
"LegacyAttributesResponse",
|
"AttributesEnvelope",
|
||||||
"LegacyCategoryCreateResponse",
|
"CategoryCreateEnvelope",
|
||||||
"LegacyAttributeCreateResponse",
|
"AttributeCreateEnvelope",
|
||||||
"LegacySuccessMessage",
|
"SuccessMessage",
|
||||||
"category_unique_id",
|
"category_unique_id",
|
||||||
"attribute_key",
|
"attribute_key",
|
||||||
"parent_id",
|
"parent_id",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
func TestInviteAcceptURLAlwaysShareable(t *testing.T) {
|
func TestInviteAcceptURLAlwaysShareable(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
url := mail.AcceptInviteURL("https://app.example.com/", "tok_abc")
|
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 {
|
if url != want {
|
||||||
t.Fatalf("AcceptInviteURL = %q want %q", url, want)
|
t.Fatalf("AcceptInviteURL = %q want %q", url, want)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func assertGateBody(t *testing.T, rec *httptest.ResponseRecorder, errorCode string, maintenance, readOnly bool) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var body map[string]any
|
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")
|
Error(w, http.StatusInternalServerError, "load failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
items = processing.ApplyV1SEOMetaPolicy(cid, items)
|
||||||
JSON(w, http.StatusOK, processing.FormatJobStatusResponse(job, items, true))
|
JSON(w, http.StatusOK, processing.FormatJobStatusResponse(job, items, true))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -8,6 +9,10 @@ import (
|
|||||||
|
|
||||||
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
|
"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/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 (
|
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 {
|
func nullIfEmptyAny(v any) any {
|
||||||
if v == nil {
|
if v == nil {
|
||||||
return 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))
|
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
|
// handleV1ListProductQuality serves GET /api/v1/products/quality with the legacy
|
||||||
// { data, meta } envelope (quality rows + page/limit/total).
|
// { data, meta } envelope (quality rows + page/limit/total).
|
||||||
func (s *Server) handleV1ListProductQuality(w http.ResponseWriter, r *http.Request) {
|
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) {
|
func TestV1ProductStatusAll(t *testing.T) {
|
||||||
if got := v1ProductStatus("all"); got != "" {
|
if got := v1ProductStatus("all"); got != "" {
|
||||||
t.Fatalf("all -> %q want empty", got)
|
t.Fatalf("all -> %q want empty", got)
|
||||||
@@ -99,7 +144,7 @@ func TestV1OpenAPIIncludesLegacyProductsEnvelope(t *testing.T) {
|
|||||||
"name: sortBy",
|
"name: sortBy",
|
||||||
"name: feedId",
|
"name: feedId",
|
||||||
"totalPages",
|
"totalPages",
|
||||||
"LegacyLimit",
|
"PublicLimit",
|
||||||
"required: [data, meta]",
|
"required: [data, meta]",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(body, needle) {
|
if !strings.Contains(body, needle) {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
@@ -66,7 +65,7 @@ func (s *Server) mountV1(r chi.Router) {
|
|||||||
// Not an alias of POST/GET /process (flat ProcessingJob).
|
// Not an alias of POST/GET /process (flat ProcessingJob).
|
||||||
r.Post("/products/process", s.handleV1StartProcess)
|
r.Post("/products/process", s.handleV1StartProcess)
|
||||||
r.Get("/products/process/{id}", s.handleV1GetProcess)
|
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)
|
r.Patch("/products/{id}", s.handleUpdateProduct)
|
||||||
|
|
||||||
// Content calendar — separate from email /api/campaigns (session UI).
|
// 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.Get("/categories", s.handleV1ListCategories)
|
||||||
r.Post("/categories", s.handleV1CreateCategory)
|
r.Post("/categories", s.handleV1CreateCategory)
|
||||||
r.Post("/categories/create", s.handleV1CreateCategory) // legacy alias
|
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.Patch("/categories/{id}", s.handleUpdateCategory)
|
||||||
r.Delete("/categories/{id}", s.handleV1DeleteCategory)
|
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.
|
// Dashboard-style jobs (flat JSON / 202). Prefer /products/process for legacy integrations.
|
||||||
r.Post("/process", s.handleStartProcessingJob)
|
r.Post("/process", s.handleStartProcessingJob)
|
||||||
r.Get("/process", s.handleV1ListProcessJobs)
|
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}/cancel", s.handleCancelProcessingJob)
|
||||||
r.Post("/process/{id}/terminate", s.handleCancelProcessingJob)
|
r.Post("/process/{id}/terminate", s.handleCancelProcessingJob)
|
||||||
r.Post("/process/{id}/retry", s.handleRetryProcessingJob)
|
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")
|
Error(w, http.StatusInternalServerError, "list failed")
|
||||||
return
|
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) {
|
func (s *Server) handleV1OpenAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ func TestV1OpenAPIDocumentsPublicAPIAuth(t *testing.T) {
|
|||||||
"Retry-After",
|
"Retry-After",
|
||||||
"rate limit exceeded",
|
"rate limit exceeded",
|
||||||
"code: unauthorized",
|
"code: unauthorized",
|
||||||
"LegacyAPIError",
|
"CodedAPIError",
|
||||||
"security: []",
|
"security: []",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(body, want) {
|
if !strings.Contains(body, want) {
|
||||||
|
|||||||
@@ -154,9 +154,9 @@ func TestV1OpenAPIIncludesProcessAndFeeds(t *testing.T) {
|
|||||||
"raw_product_ids",
|
"raw_product_ids",
|
||||||
"items[].ean",
|
"items[].ean",
|
||||||
"process_id",
|
"process_id",
|
||||||
"LegacyStartProcessByEAN",
|
"StartProcessByEAN",
|
||||||
"StartProcessByRawIDs",
|
"StartProcessByRawIDs",
|
||||||
"LegacyProcessCompleted",
|
"ProcessCompletedExample",
|
||||||
"X-API-Key",
|
"X-API-Key",
|
||||||
"https://descrybe.io/api/v1",
|
"https://descrybe.io/api/v1",
|
||||||
"BearerAuth",
|
"BearerAuth",
|
||||||
@@ -185,7 +185,7 @@ func TestV1OpenAPIIncludesProcessAndFeeds(t *testing.T) {
|
|||||||
"CSRFHeader",
|
"CSRFHeader",
|
||||||
"code: unauthorized",
|
"code: unauthorized",
|
||||||
"message: Unauthorized",
|
"message: Unauthorized",
|
||||||
"legacy envelope",
|
"coded envelope",
|
||||||
"/process/{id}/retry:",
|
"/process/{id}/retry:",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(body, needle) {
|
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())
|
t.Fatalf("get category status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
gotCat := decode(t, rec)
|
gotCat := decode(t, rec)
|
||||||
if _, hasData := gotCat["data"]; hasData {
|
gotCatData, _ := gotCat["data"].(map[string]any)
|
||||||
t.Fatalf("GET /categories/{uuid} should be flat dashboard JSON, got envelope: %v", gotCat)
|
if gotCatData["unique_id"] != catUnique {
|
||||||
}
|
|
||||||
if gotCat["unique_id"] != catUnique {
|
|
||||||
t.Fatalf("get category=%v", gotCat)
|
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(),
|
rec = do(http.MethodPatch, "/api/v1/categories/"+catUUID.String(),
|
||||||
`{"name":"Electronics & Audio"}`)
|
`{"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())
|
t.Fatalf("get product status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
gotProd := decode(t, rec)
|
gotProd := decode(t, rec)
|
||||||
if _, hasData := gotProd["data"]; hasData {
|
gotProdData, _ := gotProd["data"].(map[string]any)
|
||||||
t.Fatalf("GET /products/{id} should be flat JSON, got envelope: %v", gotProd)
|
if fmt.Sprint(gotProdData["product_id"]) != "SKU-1001" {
|
||||||
}
|
|
||||||
if fmt.Sprint(gotProd["product_id"]) != "SKU-1001" {
|
|
||||||
t.Fatalf("get product=%v", gotProd)
|
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(),
|
rec = do(http.MethodPatch, "/api/v1/products/"+productID.String(),
|
||||||
`{"processed_name":"Acme Wireless Earbuds ANC Midnight","status":"completed"}`)
|
`{"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.Route("/api/v1", func(r chi.Router) {
|
||||||
r.Get("/products", s.handleV1ListProducts)
|
r.Get("/products", s.handleV1ListProducts)
|
||||||
r.Get("/products/quality", s.handleV1ListProductQuality)
|
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.Patch("/products/{id}", s.handleUpdateProduct)
|
||||||
|
|
||||||
r.Get("/marketing/calendar", s.handleGetMarketingCalendar)
|
r.Get("/marketing/calendar", s.handleGetMarketingCalendar)
|
||||||
@@ -438,7 +447,7 @@ func mountV1DomainTestRouter(s *Server) http.Handler {
|
|||||||
r.Get("/categories", s.handleV1ListCategories)
|
r.Get("/categories", s.handleV1ListCategories)
|
||||||
r.Post("/categories", s.handleV1CreateCategory)
|
r.Post("/categories", s.handleV1CreateCategory)
|
||||||
r.Post("/categories/create", 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.Patch("/categories/{id}", s.handleUpdateCategory)
|
||||||
r.Delete("/categories/{id}", s.handleV1DeleteCategory)
|
r.Delete("/categories/{id}", s.handleV1DeleteCategory)
|
||||||
|
|
||||||
@@ -480,9 +489,9 @@ func TestV1OpenAPIDocumentsDomainCRUDSurface(t *testing.T) {
|
|||||||
"/marketing/calendar:",
|
"/marketing/calendar:",
|
||||||
"/products:",
|
"/products:",
|
||||||
"/feeds/{id}/sync-process-sample:",
|
"/feeds/{id}/sync-process-sample:",
|
||||||
"Flat category JSON",
|
"no prompts or formulas",
|
||||||
"flat PresentFeed",
|
"flat PresentFeed",
|
||||||
"flat ProcessedProduct",
|
"without mapped_data",
|
||||||
"CategoryDetail",
|
"CategoryDetail",
|
||||||
"FeedDeleted",
|
"FeedDeleted",
|
||||||
"FeedMappings",
|
"FeedMappings",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ func TestV1OpenAPIFeedsLegacyContract(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
body := string(v1OpenAPIYAML)
|
body := string(v1OpenAPIYAML)
|
||||||
for _, needle := range []string{
|
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]",
|
"required: [name, item_path]",
|
||||||
"jobId:",
|
"jobId:",
|
||||||
"FeedSyncResponse",
|
"FeedSyncResponse",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
// to the live nested object from eprel.MergeInto / extractEPRELFromAttrs.
|
||||||
func TestV1OpenAPIYAMLLegacyProcessItemEprelShape(t *testing.T) {
|
func TestV1OpenAPIYAMLProcessItemEprelShape(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
root := mustParseOpenAPIRoot(t, v1OpenAPIYAML)
|
root := mustParseOpenAPIRoot(t, v1OpenAPIYAML)
|
||||||
comps, _ := root["components"].(map[string]any)
|
comps, _ := root["components"].(map[string]any)
|
||||||
schemas, _ := comps["schemas"].(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)
|
itemProps, _ := item["properties"].(map[string]any)
|
||||||
eprel, _ := itemProps["eprel"].(map[string]any)
|
eprel, _ := itemProps["eprel"].(map[string]any)
|
||||||
eprelProps, _ := eprel["properties"].(map[string]any)
|
eprelProps, _ := eprel["properties"].(map[string]any)
|
||||||
if eprelProps == nil {
|
if eprelProps == nil {
|
||||||
t.Fatal("LegacyProcessItem.eprel.properties missing")
|
t.Fatal("ProcessItem.eprel.properties missing")
|
||||||
}
|
}
|
||||||
want := []string{"id", "label", "pdf", "energy_class", "energy_scale"}
|
want := []string{"id", "label", "pdf", "energy_class", "energy_scale"}
|
||||||
for _, key := range want {
|
for _, key := range want {
|
||||||
if _, ok := eprelProps[key]; !ok {
|
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) {
|
if len(eprelProps) != len(want) {
|
||||||
@@ -441,7 +441,161 @@ func TestV1OpenAPIYAMLLegacyProcessItemEprelShape(t *testing.T) {
|
|||||||
for k := range eprelProps {
|
for k := range eprelProps {
|
||||||
keys = append(keys, k)
|
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")
|
v1Err(w, http.StatusInternalServerError, "internal_server_error", "Internal server error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
items = processing.ApplyV1SEOMetaPolicy(cid, items)
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
"status": status,
|
"status": status,
|
||||||
"process_id": job.ID.String(),
|
"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)
|
v1OK(w, http.StatusOK, data, nil)
|
||||||
case "FAILED":
|
case "FAILED":
|
||||||
errMsg := "Processing failed"
|
errMsg := "Processing failed"
|
||||||
if job.Error != nil && *job.Error != "" {
|
if job.Error != nil && strings.TrimSpace(*job.Error) != "" {
|
||||||
errMsg = *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{
|
v1OK(w, http.StatusOK, map[string]any{
|
||||||
"status": status,
|
"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) {
|
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 {
|
if s != nil && s.testEnsureRawV1Items != nil {
|
||||||
return s.testEnsureRawV1Items(ctx, companyID, items)
|
return s.testEnsureRawV1Items(ctx, companyID, items)
|
||||||
|
|||||||
@@ -337,7 +337,6 @@ func TestHandleV1GetProcessCompletedIncludesProcessedItems(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||||
jobID := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
jobID := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||||
productID := "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
|
||||||
s := &Server{
|
s := &Server{
|
||||||
testGetJob: func(_ context.Context, companyID, id uuid.UUID) (processing.Job, error) {
|
testGetJob: func(_ context.Context, companyID, id uuid.UUID) (processing.Job, error) {
|
||||||
if companyID != cid || id != jobID {
|
if companyID != cid || id != jobID {
|
||||||
@@ -357,7 +356,7 @@ func TestHandleV1GetProcessCompletedIncludesProcessedItems(t *testing.T) {
|
|||||||
}
|
}
|
||||||
return []processing.V1ProcessJobItem{{
|
return []processing.V1ProcessJobItem{{
|
||||||
"ean": "0123456789012",
|
"ean": "0123456789012",
|
||||||
"id": productID,
|
"id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||||
"status": "processed",
|
"status": "processed",
|
||||||
"title": "Acme Widget",
|
"title": "Acme Widget",
|
||||||
}}, nil
|
}}, nil
|
||||||
@@ -393,9 +392,12 @@ func TestHandleV1GetProcessCompletedIncludesProcessedItems(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("item=%T", items[0])
|
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)
|
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 {
|
if _, ok := body.Data["processed_at"]; !ok {
|
||||||
t.Fatalf("missing processed_at: %v", body.Data)
|
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) {
|
func TestHandleV1ListProcessJobsUnauthorizedWithoutCompany(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
s := &Server{}
|
s := &Server{}
|
||||||
@@ -500,3 +586,126 @@ func TestAssertV1ProcessGatesNilBilling(t *testing.T) {
|
|||||||
t.Fatalf("nil billing must no-op: %v", err)
|
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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package mail
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
@@ -109,31 +110,33 @@ func hasHeaderBreak(v string) bool {
|
|||||||
func InviteMessage(webOrigin, email, token, companyName string) Message {
|
func InviteMessage(webOrigin, email, token, companyName string) Message {
|
||||||
link := AcceptInviteURL(webOrigin, token)
|
link := AcceptInviteURL(webOrigin, token)
|
||||||
text := fmt.Sprintf("You have been invited to %s on Descrybe.\n\nAccept: %s\n", companyName, link)
|
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>`,
|
`<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).
|
// 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 {
|
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.
|
// 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 {
|
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 {
|
func SetPasswordMessage(webOrigin, email, token string) Message {
|
||||||
link := SetPasswordURL(webOrigin, token)
|
link := SetPasswordURL(webOrigin, token)
|
||||||
text := fmt.Sprintf("Set your Descrybe password:\n\n%s\n\nThis link expires in 72 hours.\n", link)
|
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>`,
|
`<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).
|
// 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",
|
"Your Descrybe account was migrated. Set your password here:\n\n%s\n\nIf you did not expect this email, ignore it.\n",
|
||||||
link,
|
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>`,
|
`<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.
|
// ResetPasswordURL builds the self-serve forgot-password reset link.
|
||||||
|
|||||||
@@ -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 <script>") || !strings.Contains(msg.HTML, "& 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) {
|
func TestSetPasswordURL(t *testing.T) {
|
||||||
got := SetPasswordURL("http://localhost:5174/", "tok123")
|
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 {
|
if got != want {
|
||||||
t.Fatalf("SetPasswordURL=%q want %q", 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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -185,11 +185,13 @@ func Handler() http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Gate restricts Prometheus scrapes in production: allow when metricsPublic is true
|
// 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 {
|
func Gate(isProduction, metricsPublic bool) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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)
|
next.ServeHTTP(w, r)
|
||||||
return
|
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 {
|
func isLoopbackRemoteAddr(remoteAddr string) bool {
|
||||||
host := strings.TrimSpace(remoteAddr)
|
host := strings.TrimSpace(remoteAddr)
|
||||||
if host == "" {
|
if host == "" {
|
||||||
|
|||||||
@@ -134,3 +134,17 @@ func TestGateAllowsPublicFlagInProduction(t *testing.T) {
|
|||||||
t.Fatalf("METRICS_PUBLIC status=%d", rec.Code)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,11 +67,17 @@ func truncateRunes(s string, max int) string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
publicErrProcessingFailed = "processing_failed"
|
||||||
|
publicErrProviderUnavailable = "provider_unavailable"
|
||||||
|
)
|
||||||
|
|
||||||
// TruncateError returns a safe, short error string for DB storage / API clients.
|
// TruncateError returns a safe, short error string for DB storage / API clients.
|
||||||
// Secret-like substrings and logredact matches become an opaque message so
|
// 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.
|
// 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
|
// 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 {
|
func TruncateError(err error) string {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -94,20 +100,101 @@ func TruncateError(err error) string {
|
|||||||
return friendly
|
return friendly
|
||||||
}
|
}
|
||||||
// Drop retry-exhaustion wrapper when the inner message is already clear.
|
// 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{
|
for _, prefix := range []string{
|
||||||
"openai retries exhausted: ",
|
"openai retries exhausted: ",
|
||||||
"openai embedding retries exhausted: ",
|
"openai embedding retries exhausted: ",
|
||||||
} {
|
} {
|
||||||
if strings.HasPrefix(strings.ToLower(cleaned), prefix) {
|
if strings.HasPrefix(strings.ToLower(cleaned), prefix) {
|
||||||
cleaned = strings.TrimSpace(cleaned[len(prefix):])
|
return strings.TrimSpace(cleaned[len(prefix):])
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if friendly := classifyProviderError(cleaned); friendly != "" {
|
return cleaned
|
||||||
return friendly
|
}
|
||||||
|
|
||||||
|
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
|
// classifyProviderError maps common OpenAI-compatible transport/auth failures
|
||||||
|
|||||||
@@ -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) {
|
func TestTruncateError_classifiesProviderFailures(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
in string
|
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: "openai http 401", want: "AI provider rejected the API key"},
|
||||||
{in: "rate limited or server error", want: "AI provider temporarily unavailable"},
|
{in: "rate limited or server error", want: "AI provider temporarily unavailable"},
|
||||||
{in: "too many requests", want: "AI provider rate limited"},
|
{in: "too many requests", want: "AI provider rate limited"},
|
||||||
{
|
{in: "upstream 503: model overloaded", want: "provider_unavailable"},
|
||||||
in: "upstream 503: model overloaded", want: "upstream 503: model overloaded"},
|
{in: "openai retries exhausted: green-chat unavailable", want: "provider_unavailable"},
|
||||||
{
|
|
||||||
in: "openai retries exhausted: green-chat unavailable", want: "green-chat unavailable"},
|
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
got := TruncateError(errString(tc.in))
|
got := TruncateError(errString(tc.in))
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ func TestV1ProcessJobItemMarshalJSON_order(t *testing.T) {
|
|||||||
"category": "Hladilniki",
|
"category": "Hladilniki",
|
||||||
"category_id": "11",
|
"category_id": "11",
|
||||||
"processed_product_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
"processed_product_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||||
|
"gpt_response": "should-not-ship",
|
||||||
"status": "processed",
|
"status": "processed",
|
||||||
}
|
}
|
||||||
b, err := item.MarshalJSON()
|
b, err := item.MarshalJSON()
|
||||||
@@ -56,11 +57,15 @@ func TestV1ProcessJobItemMarshalJSON_order(t *testing.T) {
|
|||||||
eanAt := strings.Index(s, `"ean"`)
|
eanAt := strings.Index(s, `"ean"`)
|
||||||
titleAt := strings.Index(s, `"title"`)
|
titleAt := strings.Index(s, `"title"`)
|
||||||
attrsAt := strings.Index(s, `"attributes"`)
|
attrsAt := strings.Index(s, `"attributes"`)
|
||||||
ppAt := strings.Index(s, `"processed_product_id"`)
|
if eanAt < 0 || titleAt < 0 || attrsAt < 0 {
|
||||||
if eanAt < 0 || titleAt < 0 || attrsAt < 0 || ppAt < 0 {
|
|
||||||
t.Fatalf("missing keys in %s", s)
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"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.
|
// Important catalog fields first; internal ids / SEO last.
|
||||||
var v1ProcessItemKeyOrder = []string{
|
var v1ProcessItemKeyOrder = []string{
|
||||||
"ean",
|
"ean",
|
||||||
@@ -23,13 +23,39 @@ var v1ProcessItemKeyOrder = []string{
|
|||||||
"error",
|
"error",
|
||||||
"meta_title",
|
"meta_title",
|
||||||
"meta_description",
|
"meta_description",
|
||||||
"id",
|
|
||||||
"processed_product_id",
|
|
||||||
"raw_product_id",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON emits LegacyProcessItem keys in a stable human-readable order.
|
// v1ProcessItemInternalKeys must never appear on public ProcessItem JSON.
|
||||||
// ASSUMPTION: JSON object key order is part of the V1 readability contract for A1.
|
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) {
|
func (item V1ProcessJobItem) MarshalJSON() ([]byte, error) {
|
||||||
if item == nil {
|
if item == nil {
|
||||||
return []byte("null"), nil
|
return []byte("null"), nil
|
||||||
@@ -55,43 +81,15 @@ func (item V1ProcessJobItem) MarshalJSON() ([]byte, error) {
|
|||||||
buf.Write(vb)
|
buf.Write(vb)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
seen := map[string]struct{}{}
|
|
||||||
for _, k := range v1ProcessItemKeyOrder {
|
for _, k := range v1ProcessItemKeyOrder {
|
||||||
v, ok := item[k]
|
v, ok := item[k]
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[k] = struct{}{}
|
|
||||||
if err := writePair(k, v); err != nil {
|
if err := writePair(k, v); err != nil {
|
||||||
return nil, err
|
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('}')
|
buf.WriteByte('}')
|
||||||
return buf.Bytes(), nil
|
return buf.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
|
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
|
||||||
"github.com/descrybe/descrybe-v2/apps/api/internal/eprel"
|
"github.com/descrybe/descrybe-v2/apps/api/internal/eprel"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
var v1PartialSteps = []string{"category", "title", "description", "attributes"}
|
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",
|
"error": "Product data not available",
|
||||||
}
|
}
|
||||||
if itemError != nil && *itemError != "" {
|
if itemError != nil && *itemError != "" {
|
||||||
item["error"] = *itemError
|
item["error"] = PublicV1Error(*itemError)
|
||||||
}
|
}
|
||||||
full = append(full, item)
|
full = append(full, item)
|
||||||
continue
|
continue
|
||||||
@@ -309,6 +310,9 @@ func (p *Pipeline) LoadV1ProcessJobItems(ctx context.Context, companyID, jobID u
|
|||||||
if descTxt != nil {
|
if descTxt != nil {
|
||||||
descOut = v1PreserveDescription(*descTxt)
|
descOut = v1PreserveDescription(*descTxt)
|
||||||
}
|
}
|
||||||
|
if isPromptLeakageTitle(descOut) || isPromptLabelTitle(descOut) {
|
||||||
|
descOut = ""
|
||||||
|
}
|
||||||
|
|
||||||
eprelVal := extractEPRELFromAttrs(attrs)
|
eprelVal := extractEPRELFromAttrs(attrs)
|
||||||
attrs = SanitizeV1ProcessAttributesAllowed(attrs, allowedAttrs)
|
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
|
// 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.
|
// the public V1 process item shape — use catalog APIs when a UUID is needed.
|
||||||
if itemError != nil && *itemError != "" {
|
if itemError != nil && *itemError != "" {
|
||||||
item["error"] = *itemError
|
item["error"] = PublicV1Error(*itemError)
|
||||||
}
|
}
|
||||||
if len(attrs) > 0 {
|
if len(attrs) > 0 {
|
||||||
item["attributes"] = attrs
|
item["attributes"] = attrs
|
||||||
@@ -484,35 +488,72 @@ func nullIfEmptyPtr(s *string) any {
|
|||||||
return *s
|
return *s
|
||||||
}
|
}
|
||||||
|
|
||||||
// companyOmitsSEOMeta is true when V1/process must omit meta_title / meta_description:
|
// Known v2 companies.id values that omit SEO meta on V1 process payloads.
|
||||||
// A1 cohort (legacy id), Platform Demo (A1-cloned prompts, no legacy id), or any
|
// A1 Slovenija is also matched via billing.A1LegacyCompanyID (legacy_company_id / PK).
|
||||||
// company whose categories store A1-style role-section enhance prompts.
|
const (
|
||||||
func companyOmitsSEOMeta(ctx context.Context, p *Pipeline, companyID uuid.UUID) bool {
|
a1V2CompanyID = "604f23a8-b66e-4b21-8b45-0d72b68f4790"
|
||||||
if p == nil || p.Pool == nil {
|
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
|
return false
|
||||||
}
|
}
|
||||||
var legacy, name string
|
var legacy, name string
|
||||||
err := p.Pool.QueryRow(ctx, `
|
err := pool.QueryRow(ctx, `
|
||||||
SELECT COALESCE(legacy_company_id, ''), COALESCE(name, '')
|
SELECT COALESCE(legacy_company_id, ''), COALESCE(name, '')
|
||||||
FROM companies
|
FROM companies
|
||||||
WHERE id = $1`, companyID).Scan(&legacy, &name)
|
WHERE id = $1`, companyID).Scan(&legacy, &name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if billing.IsA1CohortCompany(legacy, "") {
|
if CompanyOmitsSEOMetaLookup(companyID, legacy, name, false) {
|
||||||
return true
|
|
||||||
}
|
|
||||||
if strings.EqualFold(strings.TrimSpace(name), "Platform Demo") {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
var hasA1Prompts bool
|
var hasA1Prompts bool
|
||||||
err = p.Pool.QueryRow(ctx, `
|
err = pool.QueryRow(ctx, `
|
||||||
SELECT EXISTS (
|
SELECT EXISTS (
|
||||||
SELECT 1 FROM categories
|
SELECT 1 FROM categories
|
||||||
WHERE company_id = $1
|
WHERE company_id = $1
|
||||||
AND prompt ILIKE '%--- Title ---%'
|
AND prompt ILIKE '%--- Title ---%'
|
||||||
AND prompt ILIKE '%--- Description ---%'
|
AND prompt ILIKE '%--- Description ---%'
|
||||||
AND prompt ILIKE '%--- Meta ---%'
|
AND prompt ILIKE '%--- Meta ---%'
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
)`, companyID).Scan(&hasA1Prompts)
|
)`, companyID).Scan(&hasA1Prompts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -521,6 +562,34 @@ func companyOmitsSEOMeta(ctx context.Context, p *Pipeline, companyID uuid.UUID)
|
|||||||
return hasA1Prompts
|
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 {
|
func derefStringPtr(s *string) string {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return ""
|
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) {
|
func TestFormatJobStatusResponseAddsItems(t *testing.T) {
|
||||||
jobID := mustParseTestUUID(t, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
jobID := mustParseTestUUID(t, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||||
job := Job{ID: jobID, Status: "completed", TotalProducts: 1}
|
job := Job{ID: jobID, Status: "completed", TotalProducts: 1}
|
||||||
@@ -218,3 +249,33 @@ func mustParseTestUUID(t *testing.T, s string) uuid.UUID {
|
|||||||
}
|
}
|
||||||
return id
|
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
|
// V1ProcessItemScorecard scores one COMPLETED legacy process item against
|
||||||
// OpenAPI LegacyProcessItem + A1 poll expectations.
|
// OpenAPI ProcessItem + A1 poll expectations.
|
||||||
type V1ProcessItemScorecard struct {
|
type V1ProcessItemScorecard struct {
|
||||||
EAN string `json:"ean"`
|
EAN string `json:"ean"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
@@ -184,7 +184,7 @@ func ScoreV1ProcessCompletedItem(item V1ProcessJobItem, opts ScoreV1ProcessItemO
|
|||||||
return sc
|
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,
|
// nonempty description when title exists (formula HTML preserved), readable title spacing,
|
||||||
// category display name as primary, optional SEO meta (unless omitSEOMeta), clean attrs.
|
// category display name as primary, optional SEO meta (unless omitSEOMeta), clean attrs.
|
||||||
// allowed is the company attribute_key set (canonicalized). When nil, only
|
// allowed is the company attribute_key set (canonicalized). When nil, only
|
||||||
@@ -209,6 +209,7 @@ func EnforceV1ProcessCompletedItemOpts(item V1ProcessJobItem, opts EnforceV1Opts
|
|||||||
if item == nil {
|
if item == nil {
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
stripV1ProcessItemInternalKeys(item)
|
||||||
status, _ := item["status"].(string)
|
status, _ := item["status"].(string)
|
||||||
if status != "processed" {
|
if status != "processed" {
|
||||||
return item
|
return item
|
||||||
@@ -230,6 +231,9 @@ func EnforceV1ProcessCompletedItemOpts(item V1ProcessJobItem, opts EnforceV1Opts
|
|||||||
if title == "" {
|
if title == "" {
|
||||||
title = ensureReadableTitleSpacing(stringFromItem(item, "title"))
|
title = ensureReadableTitleSpacing(stringFromItem(item, "title"))
|
||||||
}
|
}
|
||||||
|
if isPromptLabelTitle(title) {
|
||||||
|
title = preferredProductTitle(stringFromItem(item, "ean"), title)
|
||||||
|
}
|
||||||
if title != "" {
|
if title != "" {
|
||||||
item["name"] = title
|
item["name"] = title
|
||||||
delete(item, "title")
|
delete(item, "title")
|
||||||
@@ -237,9 +241,6 @@ func EnforceV1ProcessCompletedItemOpts(item V1ProcessJobItem, opts EnforceV1Opts
|
|||||||
item["name"] = nil
|
item["name"] = nil
|
||||||
delete(item, "title")
|
delete(item, "title")
|
||||||
}
|
}
|
||||||
delete(item, "id")
|
|
||||||
delete(item, "processed_product_id")
|
|
||||||
delete(item, "raw_product_id")
|
|
||||||
|
|
||||||
catID, catName := projectV1CategoryFields(item)
|
catID, catName := projectV1CategoryFields(item)
|
||||||
catLabel := catName
|
catLabel := catName
|
||||||
@@ -248,6 +249,9 @@ func EnforceV1ProcessCompletedItemOpts(item V1ProcessJobItem, opts EnforceV1Opts
|
|||||||
}
|
}
|
||||||
|
|
||||||
desc, _ := descriptionFromItem(item)
|
desc, _ := descriptionFromItem(item)
|
||||||
|
if isPromptLeakageTitle(desc) || isPromptLabelTitle(desc) {
|
||||||
|
desc = ""
|
||||||
|
}
|
||||||
needsDesc := title != "" && (desc == "" ||
|
needsDesc := title != "" && (desc == "" ||
|
||||||
isWeakPriorEnhanceDescription(desc, title) ||
|
isWeakPriorEnhanceDescription(desc, title) ||
|
||||||
descriptionEchoesTitle(desc, title) ||
|
descriptionEchoesTitle(desc, title) ||
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestScoreV1ProcessCompletedItem_processedStructure(t *testing.T) {
|
func TestScoreV1ProcessCompletedItem_processedStructure(t *testing.T) {
|
||||||
@@ -149,11 +152,25 @@ func TestEnforceV1ProcessCompletedItem_fillsDescriptionMetaSanitizes(t *testing.
|
|||||||
|
|
||||||
func TestEnforceV1ProcessCompletedItem_skipsTerminalStatuses(t *testing.T) {
|
func TestEnforceV1ProcessCompletedItem_skipsTerminalStatuses(t *testing.T) {
|
||||||
t.Parallel()
|
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)
|
out := EnforceV1ProcessCompletedItem(item, "", nil)
|
||||||
if _, ok := out["title"]; ok {
|
if _, ok := out["title"]; ok {
|
||||||
t.Fatalf("should not invent fields for not_found: %v", out)
|
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) {
|
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) {
|
func TestEnforceV1ProcessCompletedItem_preservesFormulaHTML(t *testing.T) {
|
||||||
t.Parallel()
|
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>`
|
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>`
|
||||||
|
|||||||
@@ -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.",
|
pl: "Członkowie zarządzają produktami i feedami. Administratorzy mogą też zapraszać współpracowników i zmieniać ustawienia firmy.",
|
||||||
ja: "メンバーは商品とフィードを管理します。管理者は同僚の招待と会社設定の変更もできます。"
|
ja: "メンバーは商品とフィードを管理します。管理者は同僚の招待と会社設定の変更もできます。"
|
||||||
},
|
},
|
||||||
"Demo sandbox is empty — switch to A1 or connect a feed to see real catalog stats.": {
|
"Demo sandbox is empty — connect a feed to see 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.",
|
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 — basculez vers A1 ou connectez un flux pour voir de vraies stats catalogue.",
|
fr: "Le bac à sable démo est vide — 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.",
|
de: "Demo-Sandbox ist leer — 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.",
|
it: "La sandbox demo è vuota — 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.",
|
pt: "A sandbox de demonstração está vazia — 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.",
|
nl: "Demo-sandbox is leeg — 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.",
|
pl: "Piaskownica demo jest pusta — podłącz feed, aby zobaczyć realne statystyki katalogu.",
|
||||||
ja: "デモサンドボックスは空です — A1に切り替えるかフィードを接続して実際のカタログ統計を表示します。"
|
ja: "デモサンドボックスは空です — フィードを接続して実際のカタログ統計を表示します。"
|
||||||
},
|
},
|
||||||
"Add a feed or upload a CSV to populate this workspace.": {
|
"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.",
|
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}.",
|
pl: "Importuj → wzbogacaj → publikuj. Przejdź do następnego kroku dla {name}.",
|
||||||
ja: "インポート → 強化 → 公開。{name} の次のステップへ。"
|
ja: "インポート → 強化 → 公開。{name} の次のステップへ。"
|
||||||
},
|
},
|
||||||
"Switch to A1 (or another seeded company) in the header, or connect a feed here to populate this sandbox.": {
|
"Connect a feed here to populate this sandbox, or switch to another company in the header.": {
|
||||||
es: "Cambia a A1 (u otra empresa con datos) en el encabezado, o conecta un feed aquí para poblar esta zona de pruebas.",
|
es: "Conecta un feed aquí para poblar esta zona de pruebas, o cambia a otra empresa en el encabezado.",
|
||||||
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.",
|
fr: "Connectez un flux ici pour remplir ce bac à sable, ou basculez vers une autre entreprise dans l'en-tête.",
|
||||||
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.",
|
de: "Verbinden Sie hier einen Feed, um diese Sandbox zu füllen, oder wechseln Sie in der Kopfzeile zu einem anderen Unternehmen.",
|
||||||
it: "Passa ad A1 (o un'altra azienda con dati) nell'intestazione, oppure collega un feed qui per popolare questa sandbox.",
|
it: "Collega un feed qui per popolare questa sandbox, oppure passa a un'altra azienda nell'intestazione.",
|
||||||
pt: "Mude para A1 (ou outra empresa com dados) no cabeçalho, ou ligue um feed aqui para preencher esta sandbox.",
|
pt: "Ligue um feed aqui para preencher esta sandbox, ou mude para outra empresa no cabeçalho.",
|
||||||
nl: "Schakel in de header over naar A1 (of een ander geseeded bedrijf), of koppel hier een feed om deze sandbox te vullen.",
|
nl: "Koppel hier een feed om deze sandbox te vullen, of schakel in de header over naar een ander bedrijf.",
|
||||||
pl: "Przełącz na A1 (lub inną firmę z danymi) w nagłówku albo podłącz tu feed, aby wypełnić tę piaskownicę.",
|
pl: "Podłącz tu feed, aby wypełnić tę piaskownicę, albo przełącz na inną firmę w nagłówku.",
|
||||||
ja: "ヘッダーでA1(または別のシード済み会社)に切り替えるか、ここでフィードを接続してサンドボックスにデータを入れます。"
|
ja: "ここでフィードを接続してサンドボックスにデータを入れるか、ヘッダーで別の会社に切り替えます。"
|
||||||
},
|
},
|
||||||
"Connect a feed or upload a CSV to start building your catalog.": {
|
"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.",
|
es: "Conecta un feed o sube un CSV para empezar a crear tu catálogo.",
|
||||||
|
|||||||
@@ -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.",
|
"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",
|
"settings.sendInvite": "Enviar invitación",
|
||||||
"dashboard.demoEmptyHint":
|
"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.workflowHint": "Importar → enriquecer → publicar. Salta al siguiente paso para {name}.",
|
||||||
"dashboard.overviewHint": "Totales en vivo para {name}",
|
"dashboard.overviewHint": "Totales en vivo para {name}",
|
||||||
"dashboard.demoEmptyMessage":
|
"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.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.emptyMessage": "Conecta un feed o sube un CSV para empezar a crear tu catálogo.",
|
||||||
"dashboard.connectFeedAnyway": "Conectar feed de todos modos",
|
"dashboard.connectFeedAnyway": "Conectar feed de todos modos",
|
||||||
|
|||||||
@@ -142,10 +142,10 @@ export const EXTRA = {
|
|||||||
"settings.inviteRole": "Rol",
|
"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.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",
|
"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.workflowHint": "Importar → enriquecer → publicar. Salta al siguiente paso para {name}.",
|
||||||
"dashboard.overviewHint": "Totales en vivo 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.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.emptyMessage": "Conecta un feed o sube un CSV para empezar a crear tu catálogo.",
|
||||||
"dashboard.connectFeedAnyway": "Conectar feed de todos modos",
|
"dashboard.connectFeedAnyway": "Conectar feed de todos modos",
|
||||||
@@ -375,10 +375,10 @@ export const EXTRA = {
|
|||||||
"settings.inviteRole": "Rôle",
|
"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.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",
|
"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.workflowHint": "Importer → enrichir → publier. Passez à l'étape suivante pour {name}.",
|
||||||
"dashboard.overviewHint": "Totaux en direct 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.emptyTitle": "Pas encore de données catalogue",
|
||||||
"dashboard.emptyMessage": "Connectez un flux ou téléversez un CSV pour commencer à construire votre 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",
|
"dashboard.connectFeedAnyway": "Connecter un flux quand même",
|
||||||
@@ -606,10 +606,10 @@ export const EXTRA = {
|
|||||||
"settings.inviteRole": "Rolle",
|
"settings.inviteRole": "Rolle",
|
||||||
"settings.inviteRoleHint": "Mitglieder verwalten Produkte und Feeds. Admins können auch Teammitglieder einladen und Unternehmenseinstellungen ändern.",
|
"settings.inviteRoleHint": "Mitglieder verwalten Produkte und Feeds. Admins können auch Teammitglieder einladen und Unternehmenseinstellungen ändern.",
|
||||||
"settings.sendInvite": "Einladung senden",
|
"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.workflowHint": "Importieren → anreichern → veröffentlichen. Zum nächsten Schritt für {name}.",
|
||||||
"dashboard.overviewHint": "Live-Summen 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.emptyTitle": "Noch keine Katalogdaten",
|
||||||
"dashboard.emptyMessage": "Verbinden Sie einen Feed oder laden Sie eine CSV hoch, um Ihren Katalog aufzubauen.",
|
"dashboard.emptyMessage": "Verbinden Sie einen Feed oder laden Sie eine CSV hoch, um Ihren Katalog aufzubauen.",
|
||||||
"dashboard.connectFeedAnyway": "Feed trotzdem verbinden",
|
"dashboard.connectFeedAnyway": "Feed trotzdem verbinden",
|
||||||
@@ -837,10 +837,10 @@ export const EXTRA = {
|
|||||||
"settings.inviteRole": "Ruolo",
|
"settings.inviteRole": "Ruolo",
|
||||||
"settings.inviteRoleHint": "I membri gestiscono prodotti e feed. Gli amministratori possono anche invitare colleghi e modificare le impostazioni dell'azienda.",
|
"settings.inviteRoleHint": "I membri gestiscono prodotti e feed. Gli amministratori possono anche invitare colleghi e modificare le impostazioni dell'azienda.",
|
||||||
"settings.sendInvite": "Invia invito",
|
"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.workflowHint": "Importa → arricchisci → pubblica. Vai al passo successivo per {name}.",
|
||||||
"dashboard.overviewHint": "Totali in tempo reale 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.emptyTitle": "Ancora nessun dato di catalogo",
|
||||||
"dashboard.emptyMessage": "Collega un feed o carica un CSV per iniziare a costruire il catalogo.",
|
"dashboard.emptyMessage": "Collega un feed o carica un CSV per iniziare a costruire il catalogo.",
|
||||||
"dashboard.connectFeedAnyway": "Collega comunque un feed",
|
"dashboard.connectFeedAnyway": "Collega comunque un feed",
|
||||||
@@ -1068,10 +1068,10 @@ export const EXTRA = {
|
|||||||
"settings.inviteRole": "Papel",
|
"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.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",
|
"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.workflowHint": "Importar → enriquecer → publicar. Salte para o passo seguinte para {name}.",
|
||||||
"dashboard.overviewHint": "Totais em direto 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.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.emptyMessage": "Ligue um feed ou carregue um CSV para começar a criar o seu catálogo.",
|
||||||
"dashboard.connectFeedAnyway": "Ligar feed mesmo assim",
|
"dashboard.connectFeedAnyway": "Ligar feed mesmo assim",
|
||||||
@@ -1299,10 +1299,10 @@ export const EXTRA = {
|
|||||||
"settings.inviteRole": "Rol",
|
"settings.inviteRole": "Rol",
|
||||||
"settings.inviteRoleHint": "Leden beheren producten en feeds. Beheerders kunnen ook teamleden uitnodigen en bedrijfsinstellingen wijzigen.",
|
"settings.inviteRoleHint": "Leden beheren producten en feeds. Beheerders kunnen ook teamleden uitnodigen en bedrijfsinstellingen wijzigen.",
|
||||||
"settings.sendInvite": "Uitnodiging verzenden",
|
"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.workflowHint": "Importeren → verrijken → publiceren. Ga naar de volgende stap voor {name}.",
|
||||||
"dashboard.overviewHint": "Live totalen 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.emptyTitle": "Nog geen catalogusgegevens",
|
||||||
"dashboard.emptyMessage": "Koppel een feed of upload een CSV om uw catalogus op te bouwen.",
|
"dashboard.emptyMessage": "Koppel een feed of upload een CSV om uw catalogus op te bouwen.",
|
||||||
"dashboard.connectFeedAnyway": "Feed toch koppelen",
|
"dashboard.connectFeedAnyway": "Feed toch koppelen",
|
||||||
@@ -1530,10 +1530,10 @@ export const EXTRA = {
|
|||||||
"settings.inviteRole": "Rola",
|
"settings.inviteRole": "Rola",
|
||||||
"settings.inviteRoleHint": "Członkowie zarządzają produktami i feedami. Administratorzy mogą też zapraszać współpracowników i zmieniać ustawienia firmy.",
|
"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",
|
"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.workflowHint": "Importuj → wzbogacaj → publikuj. Przejdź do następnego kroku dla {name}.",
|
||||||
"dashboard.overviewHint": "Bieżące sumy 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.emptyTitle": "Brak jeszcze danych katalogu",
|
||||||
"dashboard.emptyMessage": "Podłącz feed lub prześlij CSV, aby zacząć budować katalog.",
|
"dashboard.emptyMessage": "Podłącz feed lub prześlij CSV, aby zacząć budować katalog.",
|
||||||
"dashboard.connectFeedAnyway": "Podłącz feed mimo to",
|
"dashboard.connectFeedAnyway": "Podłącz feed mimo to",
|
||||||
@@ -1761,10 +1761,10 @@ export const EXTRA = {
|
|||||||
"settings.inviteRole": "ロール",
|
"settings.inviteRole": "ロール",
|
||||||
"settings.inviteRoleHint": "メンバーは商品とフィードを管理します。管理者は同僚の招待と会社設定の変更もできます。",
|
"settings.inviteRoleHint": "メンバーは商品とフィードを管理します。管理者は同僚の招待と会社設定の変更もできます。",
|
||||||
"settings.sendInvite": "招待を送信",
|
"settings.sendInvite": "招待を送信",
|
||||||
"dashboard.demoEmptyHint": "デモサンドボックスは空です — A1に切り替えるかフィードを接続して実際のカタログ統計を表示します。",
|
"dashboard.demoEmptyHint": "デモサンドボックスは空です — フィードを接続して実際のカタログ統計を表示します。",
|
||||||
"dashboard.workflowHint": "インポート → 強化 → 公開。{name} の次のステップへ。",
|
"dashboard.workflowHint": "インポート → 強化 → 公開。{name} の次のステップへ。",
|
||||||
"dashboard.overviewHint": "{name} のリアルタイム合計",
|
"dashboard.overviewHint": "{name} のリアルタイム合計",
|
||||||
"dashboard.demoEmptyMessage": "ヘッダーでA1(または別のシード済み会社)に切り替えるか、ここでフィードを接続してサンドボックスにデータを入れます。",
|
"dashboard.demoEmptyMessage": "ここでフィードを接続してサンドボックスにデータを入れるか、ヘッダーで別の会社に切り替えます。",
|
||||||
"dashboard.emptyTitle": "まだカタログデータがありません",
|
"dashboard.emptyTitle": "まだカタログデータがありません",
|
||||||
"dashboard.emptyMessage": "フィードを接続するかCSVをアップロードして、カタログの構築を開始します。",
|
"dashboard.emptyMessage": "フィードを接続するかCSVをアップロードして、カタログの構築を開始します。",
|
||||||
"dashboard.connectFeedAnyway": "それでもフィードを接続",
|
"dashboard.connectFeedAnyway": "それでもフィードを接続",
|
||||||
|
|||||||
@@ -219,15 +219,15 @@
|
|||||||
"pl": "Najpierw ustaw hasło:",
|
"pl": "Najpierw ustaw hasło:",
|
||||||
"ja": "先にパスワードを設定:"
|
"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}.": {
|
"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 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}.",
|
"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 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}.",
|
"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 der Link an eine alte Adresse ging (E-Mail-Drift), bitten Sie einen Unternehmens-Admin, eine neue Passwort-Einladung an {email} auszustellen.",
|
"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 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}.",
|
"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 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}.",
|
"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 de link naar een oud adres ging (e-maildrift), vraag dan een bedrijfsbeheerder om een nieuwe set-wachtwoorduitnodiging naar {email} te sturen.",
|
"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 link poszedł na stary adres (dryf e-mail), poproś administratora firmy o ponowne wystawienie zaproszenia do ustawienia hasła na {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} 向けのパスワード設定招待の再発行を依頼してください。"
|
"ja": "メールの招待リンクを使用してください。新しいリンクが必要な場合は、会社の管理者に {email} 向けのパスワード設定リンクの再発行を依頼してください。"
|
||||||
},
|
},
|
||||||
"your email": {
|
"your email": {
|
||||||
"es": "tu correo",
|
"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).",
|
"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": "この招待は無効または期限切れです。会社の管理者に新しい招待の送信を依頼し、新しいリンクを開くか(下に新しいトークンを貼り付けてください)。"
|
"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).": {
|
"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 o de la plataforma que lo reemita y abre el nuevo enlace (o pega el nuevo token abajo).",
|
"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 ou de la plateforme de le réémettre, puis ouvrez le nouveau lien (ou collez le nouveau jeton ci-dessous).",
|
"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- oder Plattform-Admin um Neuausstellung und öffnen Sie den neuen Link (oder fügen Sie das neue Token unten ein).",
|
"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 o della piattaforma di riemetterlo, poi apri il nuovo link (o incolla il nuovo token qui sotto).",
|
"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 ou da plataforma para o reemitir e abra o novo link (ou cole o novo token abaixo).",
|
"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 bedrijfs- of platformbeheerder om hem opnieuw uit te geven en open de nieuwe link (of plak het nieuwe token hieronder).",
|
"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 lub platformy o ponowne wystawienie, a następnie otwórz nowy link (lub wklej nowy token poniżej).",
|
"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": "このパスワード設定リンクは無効または期限切れです。会社またはプラットフォームの管理者に再発行を依頼し、新しいリンクを開くか(下に新しいトークンを貼り付けてください)。"
|
"ja": "このパスワード設定リンクは無効または期限切れです。会社の管理者にこのメール向けのパスワード設定リンクの再発行を依頼し、新しいリンクを開くか(下に新しいトークンを貼り付けてください)。"
|
||||||
},
|
},
|
||||||
"You're signed in as a different email than this invite.": {
|
"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.",
|
"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.",
|
"pl": "Jesteś zalogowany na inny e-mail niż w tym zaproszeniu.",
|
||||||
"ja": "この招待とは別のメールアドレスでログインしています。"
|
"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:",
|
"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 :",
|
"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:",
|
"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:",
|
"pl": "Ścieżka ponownego wystawienia:",
|
||||||
"ja": "再発行の手順:"
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
||||||
|
|||||||
@@ -57,19 +57,24 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pathname = event.url.pathname;
|
const pathname = event.url.pathname;
|
||||||
|
const authCredentialPath =
|
||||||
|
pathname === "/login" ||
|
||||||
|
pathname === "/register" ||
|
||||||
|
pathname === "/accept-invite" ||
|
||||||
|
pathname === "/forgot-password" ||
|
||||||
|
pathname === "/reset-password";
|
||||||
// Never keep credentials in the query string (e.g. native GET before hydration).
|
// Never keep credentials in the query string (e.g. native GET before hydration).
|
||||||
if (
|
if (authCredentialPath && event.url.searchParams.has("password")) {
|
||||||
(pathname === "/login" ||
|
|
||||||
pathname === "/register" ||
|
|
||||||
pathname === "/accept-invite" ||
|
|
||||||
pathname === "/forgot-password" ||
|
|
||||||
pathname === "/reset-password") &&
|
|
||||||
event.url.searchParams.has("password")
|
|
||||||
) {
|
|
||||||
const clean = new URL(event.url);
|
const clean = new URL(event.url);
|
||||||
clean.searchParams.delete("password");
|
clean.searchParams.delete("password");
|
||||||
|
clean.searchParams.delete("token");
|
||||||
redirect(303, `${clean.pathname}${clean.search}`);
|
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/");
|
const isRapiDocVendor = pathname.startsWith("/vendor/rapidoc/");
|
||||||
|
|
||||||
// Serve precompressed RapiDoc when client accepts gzip (copy-rapidoc-ui.mjs).
|
// Serve precompressed RapiDoc when client accepts gzip (copy-rapidoc-ui.mjs).
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import { env } from "$env/dynamic/public";
|
import { env } from "$env/dynamic/public";
|
||||||
|
import { redactSensitiveUrl } from "./auth-link-token";
|
||||||
import {
|
import {
|
||||||
CONSENT_STORAGE_KEY,
|
CONSENT_STORAGE_KEY,
|
||||||
CONSENT_WAIT_FOR_UPDATE_MS,
|
CONSENT_WAIT_FOR_UPDATE_MS,
|
||||||
@@ -165,9 +166,9 @@ export function trackPageview(path: string, opts?: { title?: string; location?:
|
|||||||
if (!isAnalyticsGranted()) return;
|
if (!isAnalyticsGranted()) return;
|
||||||
pushDataLayer({
|
pushDataLayer({
|
||||||
event: "page_view",
|
event: "page_view",
|
||||||
page_path: path,
|
page_path: redactSensitiveUrl(path),
|
||||||
page_title: opts?.title ?? document.title,
|
page_title: opts?.title ?? document.title,
|
||||||
page_location: opts?.location ?? window.location.href
|
page_location: redactSensitiveUrl(opts?.location ?? window.location.href)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,13 +90,21 @@ describe("System assistant matching", () => {
|
|||||||
["ai integrations", "open_ai_integrations"],
|
["ai integrations", "open_ai_integrations"],
|
||||||
["email sending", "open_email_integrations"],
|
["email sending", "open_email_integrations"],
|
||||||
["usage and billing", "open_billing"],
|
["usage and billing", "open_billing"],
|
||||||
["company settings", "open_settings"],
|
["company settings", "open_settings"]
|
||||||
["platform admin", "open_admin"]
|
|
||||||
] as const;
|
] as const;
|
||||||
for (const [utterance, id] of cases) {
|
for (const [utterance, id] of cases) {
|
||||||
assert.equal(matchIntent(utterance)?.intent.id, id, utterance);
|
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", () => {
|
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");
|
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();
|
const msg = buildIdentityReply();
|
||||||
assert.match(msg.text, /System assistant/i);
|
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 LLM\b/i);
|
||||||
assert.doesNotMatch(msg.text, /\bno AI\b/i);
|
assert.doesNotMatch(msg.text, /\bno AI\b/i);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function buildIdentityReply(): AssistantMessage {
|
|||||||
return makeMessage({
|
return makeMessage({
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
kind: "text",
|
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."
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type {
|
|||||||
|
|
||||||
export { INTENT_REGISTRY, intentById, QUICK_START_REPLIES } from "./intents.ts";
|
export { INTENT_REGISTRY, intentById, QUICK_START_REPLIES } from "./intents.ts";
|
||||||
export { matchIntent, matchIdentityQuestion, normalizeUtterance, extractUrl } from "./match.ts";
|
export { matchIntent, matchIdentityQuestion, normalizeUtterance, extractUrl } from "./match.ts";
|
||||||
|
export type { MatchIntentOptions } from "./match.ts";
|
||||||
export {
|
export {
|
||||||
makeMessage,
|
makeMessage,
|
||||||
idleFlow,
|
idleFlow,
|
||||||
|
|||||||
@@ -481,6 +481,7 @@ export const INTENT_REGISTRY: IntentDefinition[] = [
|
|||||||
description: "Navigate to Platform admin (staff only).",
|
description: "Navigate to Platform admin (staff only).",
|
||||||
requiresConfirm: false,
|
requiresConfirm: false,
|
||||||
canExecute: false,
|
canExecute: false,
|
||||||
|
staffOnly: true,
|
||||||
route: "/admin",
|
route: "/admin",
|
||||||
selector: '[data-assistant-target="nav-admin"],[data-tour="nav-admin"]',
|
selector: '[data-assistant-target="nav-admin"],[data-tour="nav-admin"]',
|
||||||
guideSteps: [{ title: "You're on Platform admin", detail: "Users, billing, support, and gates" }]
|
guideSteps: [{ title: "You're on Platform admin", detail: "Users, billing, support, and gates" }]
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { INTENT_REGISTRY } from "./intents.ts";
|
import { INTENT_REGISTRY } from "./intents.ts";
|
||||||
import type { IntentMatch } from "./types.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;
|
const URL_RE = /https?:\/\/[^\s<>"']+/i;
|
||||||
|
|
||||||
/** Normalize for phrase/keyword matching. */
|
/** Normalize for phrase/keyword matching. */
|
||||||
@@ -40,7 +45,7 @@ export function matchIdentityQuestion(raw: string): boolean {
|
|||||||
* Score intents by phrase containment and keyword hits.
|
* Score intents by phrase containment and keyword hits.
|
||||||
* Returns null when nothing clears the confidence floor.
|
* 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);
|
const text = normalizeUtterance(raw);
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
|
|
||||||
@@ -54,6 +59,7 @@ export function matchIntent(raw: string): IntentMatch | null {
|
|||||||
|
|
||||||
for (const intent of INTENT_REGISTRY) {
|
for (const intent of INTENT_REGISTRY) {
|
||||||
if (intent.id === "identity_system") continue;
|
if (intent.id === "identity_system") continue;
|
||||||
|
if (intent.staffOnly && !opts?.allowStaffIntents) continue;
|
||||||
let score = 0;
|
let score = 0;
|
||||||
|
|
||||||
for (const phrase of intent.phrases) {
|
for (const phrase of intent.phrases) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { trackEvent } from "$lib/analytics";
|
import { trackEvent } from "$lib/analytics";
|
||||||
|
import { authSession } from "$lib/auth-session.svelte";
|
||||||
import { intentById, QUICK_START_REPLIES } from "./intents.ts";
|
import { intentById, QUICK_START_REPLIES } from "./intents.ts";
|
||||||
import {
|
import {
|
||||||
buildApiExamplesMessage,
|
buildApiExamplesMessage,
|
||||||
@@ -557,7 +558,9 @@ function createAssistantController() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const matched = matchIntent(text);
|
const matched = matchIntent(text, {
|
||||||
|
allowStaffIntents: authSession.isSupportDesk
|
||||||
|
});
|
||||||
if (!matched) {
|
if (!matched) {
|
||||||
push(buildUnknownReply());
|
push(buildUnknownReply());
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ export type IntentDefinition = {
|
|||||||
/** Prefer data-assistant-target; falls back to data-tour. */
|
/** Prefer data-assistant-target; falls back to data-tour. */
|
||||||
selector?: string;
|
selector?: string;
|
||||||
guideSteps: AssistantStep[];
|
guideSteps: AssistantStep[];
|
||||||
|
/** When true, omit from matchIntent unless allowStaffIntents. */
|
||||||
|
staffOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IntentMatch = {
|
export type IntentMatch = {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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). */
|
/** Canonical shared intro (same idea as CategoryEnhanceUserTemplate preamble). */
|
||||||
export const DEFAULT_ENHANCE_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). */
|
/** Minimal role bodies used when a section is empty on compose (keeps markers valid). */
|
||||||
export const DEFAULT_SECTION_BODIES: Record<EnhancePromptSectionId, string> = {
|
export const DEFAULT_SECTION_BODIES: Record<EnhancePromptSectionId, string> = {
|
||||||
title:
|
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:
|
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}}',
|
"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 1–3 factual paragraphs with simple 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.',
|
meta: "Write the SEO title (50–60 characters) and SEO description (120–155 characters) as plain text, never HTML. Follow any SEO meta formula that follows; do not copy the full description into the SEO description.",
|
||||||
attributes:
|
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 = {
|
export type SectionSchemaHint = {
|
||||||
|
|||||||
@@ -31,7 +31,6 @@
|
|||||||
const companyHint = $derived.by(() => {
|
const companyHint = $derived.by(() => {
|
||||||
const n = companyName;
|
const n = companyName;
|
||||||
if (/^platform demo$/i.test(n) || /^demo$/i.test(n)) return "Platform Demo";
|
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;
|
return n;
|
||||||
});
|
});
|
||||||
const impersonating = $derived(Boolean(me.impersonating));
|
const impersonating = $derived(Boolean(me.impersonating));
|
||||||
|
|||||||
@@ -129,15 +129,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="mx-auto mt-6 max-w-3xl text-center text-sm text-text-muted">
|
<p class="mx-auto mt-6 max-w-3xl text-center text-sm text-text-muted">
|
||||||
{i18n.t("pricing.section.publicBefore")}
|
{i18n.t("pricing.section.guestPublicBefore")}
|
||||||
<a href="/plans" class="font-medium text-link hover:underline"
|
<a href="/login" class="font-medium text-link hover:underline">{i18n.t("site.logIn")}</a>
|
||||||
>{i18n.t("pricing.section.plansLink")}</a
|
{i18n.t("pricing.section.guestPublicAfter")}
|
||||||
>
|
|
||||||
{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")}
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Card class="mt-12 border-border bg-surface">
|
<Card class="mt-12 border-border bg-surface">
|
||||||
|
|||||||
@@ -132,5 +132,44 @@ export function validateDocsGuideTree(tree: DocsGuideTree): string[] {
|
|||||||
errors.push(`escapeLinks docs href must use RapiDoc ids (no braces): ${link.href}`);
|
errors.push(`escapeLinks docs href must use RapiDoc ids (no braces): ${link.href}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
collectForbiddenDocsCopy(tree, errors);
|
||||||
return 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -145,8 +145,8 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
|
|||||||
body: "Do not mix bodies or response envelopes between these two surfaces.",
|
body: "Do not mix bodies or response envelopes between these two surfaces.",
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
id: "legacy",
|
id: "ean",
|
||||||
label: "Legacy EAN batch",
|
label: "EAN batch",
|
||||||
description: "POST /products/process · { data: { process_id } }",
|
description: "POST /products/process · { data: { process_id } }",
|
||||||
nextId: "answer.process.legacy"
|
nextId: "answer.process.legacy"
|
||||||
},
|
},
|
||||||
@@ -300,7 +300,7 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
|
|||||||
endpoints: [
|
endpoints: [
|
||||||
endpoint("GET", "/health", "Liveness (also /healthz and /readyz on the API host)"),
|
endpoint("GET", "/health", "Liveness (also /healthz and /readyz on the API host)"),
|
||||||
endpoint("GET", "/openapi.yaml", "This OpenAPI document"),
|
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: [
|
links: [
|
||||||
{ label: "Settings → API keys", href: "/settings?tab=api-keys", kind: "app" },
|
{ label: "Settings → API keys", href: "/settings?tab=api-keys", kind: "app" },
|
||||||
@@ -325,7 +325,7 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
|
|||||||
endpoints: [
|
endpoints: [
|
||||||
endpoint("GET", "/health", "No key required — confirm the viewer reaches the API"),
|
endpoint("GET", "/health", "No key required — confirm the viewer reaches the API"),
|
||||||
endpoint("GET", "/products", "Authenticated smoke list (page=1&limit=1)"),
|
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")
|
endpoint("POST", "/process", "Jobs process — after Authorize")
|
||||||
],
|
],
|
||||||
links: [
|
links: [
|
||||||
@@ -417,20 +417,20 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
|
|||||||
"answer.process.legacy": {
|
"answer.process.legacy": {
|
||||||
kind: "answer",
|
kind: "answer",
|
||||||
id: "answer.process.legacy",
|
id: "answer.process.legacy",
|
||||||
pathKey: "process.legacy",
|
pathKey: "process.ean",
|
||||||
synonyms: ["ean", "items[].ean", "products/process"],
|
synonyms: ["ean", "items[].ean", "products/process"],
|
||||||
title: "Legacy public process (EAN batch)",
|
title: "Public process (EAN batch)",
|
||||||
outcome: "Start and poll enrichment using the legacy Descrybe contract.",
|
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.",
|
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,
|
authNote: AUTH_NOTE,
|
||||||
endpoints: [
|
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")
|
endpoint("GET", "/products/process/{id}", "Processing job status by process_id")
|
||||||
],
|
],
|
||||||
links: [
|
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}"),
|
href: docsOperationHref("GET", "/products/process/{id}"),
|
||||||
kind: "docs"
|
kind: "docs"
|
||||||
},
|
},
|
||||||
@@ -448,7 +448,7 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
|
|||||||
synonyms: ["raw_product_ids", "cancel job", "retry job"],
|
synonyms: ["raw_product_ids", "cancel job", "retry job"],
|
||||||
title: "Dashboard-style processing jobs",
|
title: "Dashboard-style processing jobs",
|
||||||
outcome: "Start flat JSON jobs by raw_product_ids; poll, cancel, or retry.",
|
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,
|
authNote: AUTH_NOTE,
|
||||||
endpoints: [
|
endpoints: [
|
||||||
endpoint("POST", "/process", "Start processing job by raw_product_ids"),
|
endpoint("POST", "/process", "Start processing job by raw_product_ids"),
|
||||||
@@ -473,15 +473,15 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
|
|||||||
pathKey: "process.which",
|
pathKey: "process.which",
|
||||||
synonyms: ["which process", "dual contract"],
|
synonyms: ["which process", "dual contract"],
|
||||||
title: "Which process API should I use?",
|
title: "Which process API should I use?",
|
||||||
outcome: "Pick legacy EAN for integrations; pick /process for dashboard-style jobs.",
|
outcome: "Pick EAN batch 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.",
|
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,
|
authNote: AUTH_NOTE,
|
||||||
endpoints: [
|
endpoints: [
|
||||||
endpoint("POST", "/products/process", "Legacy EAN batch"),
|
endpoint("POST", "/products/process", "EAN batch"),
|
||||||
endpoint("POST", "/process", "Dashboard-style jobs")
|
endpoint("POST", "/process", "Dashboard-style jobs")
|
||||||
],
|
],
|
||||||
links: [
|
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: "Jobs process", href: docsOperationHref("POST", "/process"), kind: "docs" },
|
||||||
{
|
{
|
||||||
label: "Dual-mode overview",
|
label: "Dual-mode overview",
|
||||||
@@ -575,13 +575,13 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
|
|||||||
endpoints: [
|
endpoints: [
|
||||||
endpoint("GET", "/categories", "List categories"),
|
endpoint("GET", "/categories", "List categories"),
|
||||||
endpoint("POST", "/categories", "Create category"),
|
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("GET", "/categories/{id}", "Get category by UUID"),
|
||||||
endpoint("PATCH", "/categories/{id}", "Update category"),
|
endpoint("PATCH", "/categories/{id}", "Update category"),
|
||||||
endpoint("DELETE", "/categories/{id}", "Delete category by unique_id"),
|
endpoint("DELETE", "/categories/{id}", "Delete category by unique_id"),
|
||||||
endpoint("GET", "/attributes", "List attributes"),
|
endpoint("GET", "/attributes", "List attributes"),
|
||||||
endpoint("POST", "/attributes", "Create attribute"),
|
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("PATCH", "/attributes/{id}", "Update attribute"),
|
||||||
endpoint("DELETE", "/attributes/{id}", "Delete 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: "Attributes tag", href: docsTagHref("Attributes"), kind: "docs" },
|
||||||
{ label: "Authorize", href: docsAuthHref(), 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"]
|
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: "Try GET /products", href: docsOperationHref("GET", "/products"), kind: "docs" },
|
||||||
{ label: "Authorize", href: docsAuthHref(), 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"]
|
relatedIds: ["answer.products.quality", "answer.process.legacy"]
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -648,13 +648,13 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
|
|||||||
synonyms: ["campaigns", "calendar", "seasonal"],
|
synonyms: ["campaigns", "calendar", "seasonal"],
|
||||||
title: "Marketing calendar (API)",
|
title: "Marketing calendar (API)",
|
||||||
outcome: "List seasonal presets and prepare a calendar export via the public 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,
|
authNote: AUTH_NOTE,
|
||||||
endpoints: [
|
endpoints: [
|
||||||
endpoint("GET", "/marketing/calendar", "List seasonal campaign presets"),
|
endpoint("GET", "/marketing/calendar", "List seasonal campaign presets"),
|
||||||
endpoint("POST", "/marketing/calendar/prepare", "Prepare seasonal campaign export"),
|
endpoint("POST", "/marketing/calendar/prepare", "Prepare seasonal campaign export"),
|
||||||
endpoint("GET", "/campaigns", "List presets (legacy alias)"),
|
endpoint("GET", "/campaigns", "List presets (alias of GET /marketing/calendar)"),
|
||||||
endpoint("POST", "/campaigns/prepare", "Prepare export (legacy alias)")
|
endpoint("POST", "/campaigns/prepare", "Prepare export (alias of POST /marketing/calendar/prepare)")
|
||||||
],
|
],
|
||||||
links: [
|
links: [
|
||||||
{
|
{
|
||||||
@@ -684,7 +684,7 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
|
|||||||
{ label: "Settings", href: "/settings", kind: "app" },
|
{ label: "Settings", href: "/settings", kind: "app" },
|
||||||
{ label: "Contact support", href: "/support/new", kind: "support" }
|
{ 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"]
|
relatedIds: ["answer.error.job", "answer.process.chooser"]
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -722,7 +722,7 @@ export const DOCS_GUIDE_TREE: DocsGuideTree = {
|
|||||||
authNote: AUTH_NOTE,
|
authNote: AUTH_NOTE,
|
||||||
endpoints: [
|
endpoints: [
|
||||||
endpoint("POST", "/feeds/{id}/sync", "Sync (rate-limited)"),
|
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", "/process", "Jobs process (rate-limited)"),
|
||||||
endpoint("POST", "/export-feeds/{id}/generate", "Generate export (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"],
|
synonyms: ["job failed", "pending", "cancel", "retry"],
|
||||||
title: "Process job pending or failed",
|
title: "Process job pending or failed",
|
||||||
outcome: "Poll the matching status endpoint; cancel/retry only on the /process job surface.",
|
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,
|
authNote: AUTH_NOTE,
|
||||||
endpoints: [
|
endpoints: [
|
||||||
endpoint("GET", "/products/process/{id}", "Legacy poll"),
|
endpoint("GET", "/products/process/{id}", "EAN batch poll"),
|
||||||
endpoint("GET", "/process/{id}", "Jobs poll"),
|
endpoint("GET", "/process/{id}", "Jobs poll"),
|
||||||
endpoint("POST", "/process/{id}/retry", "Retry failed/cancelled job")
|
endpoint("POST", "/process/{id}/retry", "Retry failed/cancelled job")
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export function readStoredTryItKey(companyId: string): DocsTryItStoredKey | null
|
|||||||
export function writeStoredTryItKey(entry: DocsTryItStoredKey): void {
|
export function writeStoredTryItKey(entry: DocsTryItStoredKey): void {
|
||||||
if (typeof sessionStorage === "undefined") return;
|
if (typeof sessionStorage === "undefined") return;
|
||||||
try {
|
try {
|
||||||
|
// Tab-scoped only. Never log the full key or send it to GTM/analytics.
|
||||||
sessionStorage.setItem(storageKey(entry.companyId), JSON.stringify(entry));
|
sessionStorage.setItem(storageKey(entry.companyId), JSON.stringify(entry));
|
||||||
} catch {
|
} catch {
|
||||||
/* quota / private mode */
|
/* quota / private mode */
|
||||||
@@ -89,6 +90,38 @@ export function clearRapiDocApiKeys(el: RapiDocAuthElement | null | undefined):
|
|||||||
el.removeAllSecurityKeys();
|
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 {
|
export function maskKeyPrefix(prefix: string | null | undefined): string {
|
||||||
const p = (prefix ?? "").trim();
|
const p = (prefix ?? "").trim();
|
||||||
return p ? `${p}\u2026` : "dk_\u2026";
|
return p ? `${p}\u2026` : "dk_\u2026";
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export const de: MessageDict = {
|
|||||||
"auth.login.failed": "Anmeldung fehlgeschlagen",
|
"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.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.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.yourEmail": "Ihre E-Mail",
|
||||||
"auth.login.platformAdminsReissue": "Plattform-Admins können erneut ausstellen unter",
|
"auth.login.platformAdminsReissue": "Plattform-Admins können erneut ausstellen unter",
|
||||||
"auth.login.adminUsersLink": "Admin → Benutzer",
|
"auth.login.adminUsersLink": "Admin → Benutzer",
|
||||||
@@ -144,9 +144,9 @@ export const de: MessageDict = {
|
|||||||
"auth.invite.acceptFailed": "Einladung konnte nicht angenommen werden",
|
"auth.invite.acceptFailed": "Einladung konnte nicht angenommen werden",
|
||||||
"auth.invite.setPasswordFailed": "Passwort konnte nicht festgelegt 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.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.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.adminUsersLink": "Admin → Benutzer",
|
||||||
"auth.invite.doneTitle": "Sie sind im Team",
|
"auth.invite.doneTitle": "Sie sind im Team",
|
||||||
"auth.invite.doneSetPasswordTitle": "Passwort gespeichert",
|
"auth.invite.doneSetPasswordTitle": "Passwort gespeichert",
|
||||||
@@ -163,7 +163,7 @@ export const de: MessageDict = {
|
|||||||
"auth.invite.switchAccountTitle": "Konto wechseln:",
|
"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.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.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.switcher": "Oberflächensprache",
|
||||||
"locale.menu": "Oberflächensprache wählen",
|
"locale.menu": "Oberflächensprache wählen",
|
||||||
"locale.label": "Dashboard-Sprache",
|
"locale.label": "Dashboard-Sprache",
|
||||||
@@ -276,7 +276,7 @@ export const de: MessageDict = {
|
|||||||
"settings.table.lastUsed": "Zuletzt verwendet",
|
"settings.table.lastUsed": "Zuletzt verwendet",
|
||||||
"dashboard.demoSandbox": "Demo-Sandbox",
|
"dashboard.demoSandbox": "Demo-Sandbox",
|
||||||
"dashboard.demoEmptyTitle": "Demo-Sandbox ist leer",
|
"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.overviewTitle": "Übersicht",
|
||||||
"dashboard.quickLinks": "Schnelllinks",
|
"dashboard.quickLinks": "Schnelllinks",
|
||||||
"dashboard.whatsNew": "Neuigkeiten",
|
"dashboard.whatsNew": "Neuigkeiten",
|
||||||
@@ -319,7 +319,7 @@ export const de: MessageDict = {
|
|||||||
"dashboard.workflow.exportMap": "Zuordnen dann exportieren",
|
"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.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.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.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.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",
|
"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.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.focus.dismiss": "Hinweis schließen",
|
||||||
"activation.dismiss": "Checkliste ausblenden",
|
"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.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.cta": "Fehlende oder falsche Daten melden",
|
||||||
"hypercare.report.dismiss": "Verwerfen",
|
"hypercare.report.dismiss": "Verwerfen",
|
||||||
"hypercare.report.adminTriage": "Hypercare-Warteschlange öffnen",
|
"hypercare.report.adminTriage": "Hypercare-Warteschlange öffnen",
|
||||||
|
|
||||||
"etl.gaps.title": "What did not migrate",
|
"etl.gaps.title": "What still needs setup",
|
||||||
"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.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.dismiss": "Dismiss",
|
||||||
"etl.gaps.apiKeys.title": "API keys",
|
"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.apiKeys.cta": "Open API keys",
|
||||||
"etl.gaps.blobs.title": "File contents (blobs)",
|
"etl.gaps.blobs.title": "Uploaded files",
|
||||||
"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.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} file records are metadata-only (bytes were not migrated).",
|
"etl.gaps.blobs.countHint": "{count} files are listed without their contents (they weren't copied).",
|
||||||
"etl.gaps.blobs.cta": "Open Files",
|
"etl.gaps.blobs.cta": "Open Files",
|
||||||
"etl.gaps.jobs.title": "Processing history",
|
"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.body": "Earlier job history wasn't imported. 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.emptyHint": "Processing history wasn't imported — an empty list is expected.",
|
||||||
"etl.gaps.jobs.migratedHint": "{count} jobs were tagged migrated from an optional backfill.",
|
"etl.gaps.jobs.migratedHint": "{count} jobs were imported from the previous workspace.",
|
||||||
"etl.gaps.jobs.cta": "Open Processing",
|
"etl.gaps.jobs.cta": "Open Processing",
|
||||||
"etl.gaps.woo.title": "Store credentials",
|
"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.woo.cta": "Open Stores",
|
||||||
"etl.gaps.settings.title": "Company settings",
|
"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.",
|
"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.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": "Nur Unternehmens-Admins können {action}. Bitten Sie einen Admin um Hilfe.",
|
||||||
"errors.companyAdminDenied.actionDefault": "dies tun",
|
"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.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": "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.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. Legacy-Schlüssel nicht migriert  Admin um Erstellung bitten oder dk_-Schlüssel einfügen.",
|
"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",
|
"errors.couldNotUpdateLanguage": "Sprache konnte nicht aktualisiert werden",
|
||||||
"toast.support.replyTitle": "Support-Antwort",
|
"toast.support.replyTitle": "Support-Antwort",
|
||||||
"toast.support.replyBody": "Das Team hat auf Ihr Support-Ticket geantwortet.",
|
"toast.support.replyBody": "Das Team hat auf Ihr Support-Ticket geantwortet.",
|
||||||
@@ -5065,7 +5065,7 @@ export const de: MessageDict = {
|
|||||||
"brand.uploadLogo": "Logo hochladen",
|
"brand.uploadLogo": "Logo hochladen",
|
||||||
"brand.clear": "Löschen",
|
"brand.clear": "Löschen",
|
||||||
"brand.field.logoUrl": "Logo-URL",
|
"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.logoAlt": "Vorschau des Markenlogos",
|
||||||
"brand.tipsTitle": "Formel- / Vorschautipps",
|
"brand.tipsTitle": "Formel- / Vorschautipps",
|
||||||
"brand.saving": "Wird gespeichert…",
|
"brand.saving": "Wird gespeichert…",
|
||||||
@@ -5291,7 +5291,7 @@ export const de: MessageDict = {
|
|||||||
"pricing.calc.enterpriseTitle": "Sounds like Enterprise",
|
"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.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.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.mostPopular": "Am beliebtesten",
|
||||||
"pricing.card.custom": "Individuell",
|
"pricing.card.custom": "Individuell",
|
||||||
"pricing.card.forever": "für immer",
|
"pricing.card.forever": "für immer",
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export const en: MessageDict = {
|
|||||||
"auth.login.failed": "Login failed",
|
"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.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.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.yourEmail": "your email",
|
||||||
"auth.login.platformAdminsReissue": "Platform admins can re-issue from",
|
"auth.login.platformAdminsReissue": "Platform admins can re-issue from",
|
||||||
"auth.login.adminUsersLink": "Admin → Users",
|
"auth.login.adminUsersLink": "Admin → Users",
|
||||||
@@ -144,9 +144,9 @@ export const en: MessageDict = {
|
|||||||
"auth.invite.acceptFailed": "Could not accept invite",
|
"auth.invite.acceptFailed": "Could not accept invite",
|
||||||
"auth.invite.setPasswordFailed": "Could not set password",
|
"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.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.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.adminUsersLink": "Admin → Users",
|
||||||
"auth.invite.doneTitle": "You're on the team",
|
"auth.invite.doneTitle": "You're on the team",
|
||||||
"auth.invite.doneSetPasswordTitle": "Password saved",
|
"auth.invite.doneSetPasswordTitle": "Password saved",
|
||||||
@@ -163,7 +163,7 @@ export const en: MessageDict = {
|
|||||||
"auth.invite.switchAccountTitle": "Switch account:",
|
"auth.invite.switchAccountTitle": "Switch account:",
|
||||||
"auth.invite.switchAccountBody": "sign out, then finish this form with the invited email{emailSuffix}.",
|
"auth.invite.switchAccountBody": "sign out, then finish this form with the invited email{emailSuffix}.",
|
||||||
"auth.invite.reissueTitle": "Re-issue path:",
|
"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.switcher": "Interface language",
|
||||||
"locale.menu": "Choose interface language",
|
"locale.menu": "Choose interface language",
|
||||||
"locale.label": "Dashboard language",
|
"locale.label": "Dashboard language",
|
||||||
@@ -296,7 +296,7 @@ export const en: MessageDict = {
|
|||||||
"settings.table.lastUsed": "Last Used",
|
"settings.table.lastUsed": "Last Used",
|
||||||
"dashboard.demoSandbox": "Demo sandbox",
|
"dashboard.demoSandbox": "Demo sandbox",
|
||||||
"dashboard.demoEmptyTitle": "Demo sandbox is empty",
|
"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.overviewTitle": "Overview",
|
||||||
"dashboard.quickLinks": "Quick links",
|
"dashboard.quickLinks": "Quick links",
|
||||||
"dashboard.whatsNew": "What's new",
|
"dashboard.whatsNew": "What's new",
|
||||||
@@ -339,7 +339,7 @@ export const en: MessageDict = {
|
|||||||
"dashboard.workflow.exportMap": "Map then export",
|
"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.workflowHint": "Catalog in → enrich / process → export or push to stores. Jump to the next step for {name}.",
|
||||||
"dashboard.overviewHint": "Live totals 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.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.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",
|
"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.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.focus.dismiss": "Dismiss hint",
|
||||||
"activation.dismiss": "Dismiss checklist",
|
"activation.dismiss": "Dismiss checklist",
|
||||||
"hypercare.report.title": "Cutover hypercare",
|
"hypercare.report.title": "Need help after the move?",
|
||||||
"hypercare.report.message": "See missing or wrong data after migration? Report it so we can fix your workspace.",
|
"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.cta": "Report missing or wrong data",
|
||||||
"hypercare.report.dismiss": "Dismiss",
|
"hypercare.report.dismiss": "Dismiss",
|
||||||
"hypercare.report.adminTriage": "Open hypercare queue",
|
"hypercare.report.adminTriage": "Open hypercare queue",
|
||||||
"etl.gaps.title": "What did not migrate",
|
"etl.gaps.title": "What still needs setup",
|
||||||
"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.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.dismiss": "Dismiss",
|
||||||
"etl.gaps.apiKeys.title": "API keys",
|
"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.apiKeys.cta": "Open API keys",
|
||||||
"etl.gaps.blobs.title": "File contents (blobs)",
|
"etl.gaps.blobs.title": "Uploaded files",
|
||||||
"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.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} file records are metadata-only (bytes were not migrated).",
|
"etl.gaps.blobs.countHint": "{count} files are listed without their contents (they weren't copied).",
|
||||||
"etl.gaps.blobs.cta": "Open Files",
|
"etl.gaps.blobs.cta": "Open Files",
|
||||||
"etl.gaps.jobs.title": "Processing history",
|
"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.body": "Earlier job history wasn't imported. 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.emptyHint": "Processing history wasn't imported — an empty list is expected.",
|
||||||
"etl.gaps.jobs.migratedHint": "{count} jobs were tagged migrated from an optional backfill.",
|
"etl.gaps.jobs.migratedHint": "{count} jobs were imported from the previous workspace.",
|
||||||
"etl.gaps.jobs.cta": "Open Processing",
|
"etl.gaps.jobs.cta": "Open Processing",
|
||||||
"etl.gaps.woo.title": "Store credentials",
|
"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.woo.cta": "Open Stores",
|
||||||
"etl.gaps.settings.title": "Company settings",
|
"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.",
|
"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.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": "Only company admins can {action}. Ask a company admin for help.",
|
||||||
"errors.companyAdminDenied.actionDefault": "do this",
|
"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.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": "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.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. Legacy keys were not migrated — ask a company admin to create one, or paste a dk_ key.",
|
"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",
|
"errors.couldNotUpdateLanguage": "Could not update language",
|
||||||
"toast.support.replyTitle": "Support reply",
|
"toast.support.replyTitle": "Support reply",
|
||||||
"toast.support.replyBody": "Staff replied to your support ticket.",
|
"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.cancelledDesc": "The just-started job was cancelled.",
|
||||||
"processing.receipt.cancelFailed": "Could not cancel processing job",
|
"processing.receipt.cancelFailed": "Could not cancel processing job",
|
||||||
"unsubscribe.title": "Unsubscribe",
|
"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.me": "Unsubscribe me",
|
||||||
"unsubscribe.invalidLink": "Invalid link",
|
"unsubscribe.invalidLink": "Invalid link",
|
||||||
"unsubscribe.failedShort": "Unsubscribe failed",
|
"unsubscribe.failedShort": "Unsubscribe failed",
|
||||||
@@ -4445,23 +4446,23 @@ export const en: MessageDict = {
|
|||||||
"categories.promptSection.navHeading": "Sections",
|
"categories.promptSection.navHeading": "Sections",
|
||||||
"categories.promptSection.navHint": "Edit one part of the enhance reply at a time.",
|
"categories.promptSection.navHint": "Edit one part of the enhance reply at a time.",
|
||||||
"categories.promptSection.title": "Title",
|
"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.titleFormulaNote": "Title formula slots (type + brand + model) are appended automatically when set on this category.",
|
||||||
"categories.promptSection.description": "Description",
|
"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.descriptionFormulaNote": "Description formula sections are appended at render time when configured.",
|
||||||
"categories.promptSection.meta": "Meta",
|
"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.metaFormulaNote": "SEO meta formulas from the description template are appended when present.",
|
||||||
"categories.promptSection.attributes": "Attributes",
|
"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.attributesFormulaNote": "Allowed attribute keys and title-formula attr slots are appended at render time.",
|
||||||
"categories.promptSection.schemaHeading": "One-shot JSON",
|
"categories.promptSection.schemaHeading": "One-shot JSON",
|
||||||
"categories.promptSection.schemaHelp": "Enhance returns a single JSON object. This section owns the keys below.",
|
"categories.promptSection.schemaHelp": "Enhance returns a single JSON object. This section owns the keys below.",
|
||||||
"categories.promptSection.contentLabel": "Section instructions",
|
"categories.promptSection.contentLabel": "Section instructions",
|
||||||
"categories.promptSection.contentPlaceholder": "Role guidance and placeholders for this section...",
|
"categories.promptSection.contentPlaceholder": "Role guidance and placeholders for this section...",
|
||||||
"categories.promptSection.preambleToggle": "Shared intro (all sections)",
|
"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.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.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.",
|
"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.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.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.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.eyebrow": "Pricing",
|
||||||
"pricing.page.title": "Simple plans for growing catalogs",
|
"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.",
|
"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.subscribedMid": "or compare plans in",
|
||||||
"pricing.page.plansLink": "Plans",
|
"pricing.page.plansLink": "Plans",
|
||||||
"pricing.page.subscribedAfter": ".",
|
"pricing.page.subscribedAfter": ".",
|
||||||
|
"pricing.page.guestSubscribedBefore": "Already subscribed?",
|
||||||
|
"pricing.page.guestSubscribedAfter": "to manage billing and compare plans.",
|
||||||
"legal.lastUpdated": "Last updated: {date}",
|
"legal.lastUpdated": "Last updated: {date}",
|
||||||
"legal.backHome": "← Back to Home",
|
"legal.backHome": "← Back to Home",
|
||||||
"legal.emailLabel": "Email:",
|
"legal.emailLabel": "Email:",
|
||||||
@@ -5130,7 +5137,7 @@ export const en: MessageDict = {
|
|||||||
"brand.uploadLogo": "Upload logo",
|
"brand.uploadLogo": "Upload logo",
|
||||||
"brand.clear": "Clear",
|
"brand.clear": "Clear",
|
||||||
"brand.field.logoUrl": "Logo URL",
|
"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.logoAlt": "Brand logo preview",
|
||||||
"brand.tipsTitle": "Formula / preview tips",
|
"brand.tipsTitle": "Formula / preview tips",
|
||||||
"brand.saving": "Saving…",
|
"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.publicBefore": "Public plans: Free, Starter, Plus, Growth, Business, Scale, and Enterprise. Create a Free account, then upgrade under",
|
||||||
"pricing.section.publicOr": "or",
|
"pricing.section.publicOr": "or",
|
||||||
"pricing.section.publicAfter": "via Stripe Checkout. Enterprise remains sales-led.",
|
"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.plansLink": "Plans",
|
||||||
"pricing.section.billingLink": "Billing",
|
"pricing.section.billingLink": "Billing",
|
||||||
"pricing.section.capabilitiesTitle": "What every plan is built for",
|
"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.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.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.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.mostPopular": "Most Popular",
|
||||||
"pricing.card.custom": "Custom",
|
"pricing.card.custom": "Custom",
|
||||||
"pricing.card.forever": "forever",
|
"pricing.card.forever": "forever",
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export const es: MessageDict = {
|
|||||||
"auth.login.failed": "Error al iniciar sesión",
|
"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.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.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.yourEmail": "tu correo",
|
||||||
"auth.login.platformAdminsReissue": "Los administradores de plataforma pueden reemitir desde",
|
"auth.login.platformAdminsReissue": "Los administradores de plataforma pueden reemitir desde",
|
||||||
"auth.login.adminUsersLink": "Admin → Usuarios",
|
"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.acceptFailed": "No se pudo aceptar la invitación",
|
||||||
"auth.invite.setPasswordFailed": "No se pudo establecer la contraseña",
|
"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.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.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.adminUsersLink": "Admin → Usuarios",
|
||||||
"auth.invite.doneTitle": "Ya formas parte del equipo",
|
"auth.invite.doneTitle": "Ya formas parte del equipo",
|
||||||
"auth.invite.doneSetPasswordTitle": "Contraseña guardada",
|
"auth.invite.doneSetPasswordTitle": "Contraseña guardada",
|
||||||
@@ -163,7 +163,7 @@ export const es: MessageDict = {
|
|||||||
"auth.invite.switchAccountTitle": "Cambiar de cuenta:",
|
"auth.invite.switchAccountTitle": "Cambiar de cuenta:",
|
||||||
"auth.invite.switchAccountBody": "cierra sesión y completa este formulario con el correo invitado{emailSuffix}.",
|
"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.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.switcher": "Idioma de la interfaz",
|
||||||
"locale.menu": "Elegir idioma de la interfaz",
|
"locale.menu": "Elegir idioma de la interfaz",
|
||||||
"locale.label": "Idioma del panel",
|
"locale.label": "Idioma del panel",
|
||||||
@@ -276,7 +276,7 @@ export const es: MessageDict = {
|
|||||||
"settings.table.lastUsed": "Último uso",
|
"settings.table.lastUsed": "Último uso",
|
||||||
"dashboard.demoSandbox": "Zona de pruebas demo",
|
"dashboard.demoSandbox": "Zona de pruebas demo",
|
||||||
"dashboard.demoEmptyTitle": "La zona de pruebas demo está vacÃÂa",
|
"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.overviewTitle": "Resumen",
|
||||||
"dashboard.quickLinks": "Enlaces rápidos",
|
"dashboard.quickLinks": "Enlaces rápidos",
|
||||||
"dashboard.whatsNew": "Novedades",
|
"dashboard.whatsNew": "Novedades",
|
||||||
@@ -319,7 +319,7 @@ export const es: MessageDict = {
|
|||||||
"dashboard.workflow.exportMap": "Mapear y exportar",
|
"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.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.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.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.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",
|
"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.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.focus.dismiss": "Cerrar aviso",
|
||||||
"activation.dismiss": "Descartar lista",
|
"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.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.cta": "Reportar datos faltantes o incorrectos",
|
||||||
"hypercare.report.dismiss": "Descartar",
|
"hypercare.report.dismiss": "Descartar",
|
||||||
"hypercare.report.adminTriage": "Abrir cola de hypercare",
|
"hypercare.report.adminTriage": "Abrir cola de hypercare",
|
||||||
|
|
||||||
"etl.gaps.title": "What did not migrate",
|
"etl.gaps.title": "What still needs setup",
|
||||||
"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.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.dismiss": "Dismiss",
|
||||||
"etl.gaps.apiKeys.title": "API keys",
|
"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.apiKeys.cta": "Open API keys",
|
||||||
"etl.gaps.blobs.title": "File contents (blobs)",
|
"etl.gaps.blobs.title": "Uploaded files",
|
||||||
"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.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} file records are metadata-only (bytes were not migrated).",
|
"etl.gaps.blobs.countHint": "{count} files are listed without their contents (they weren't copied).",
|
||||||
"etl.gaps.blobs.cta": "Open Files",
|
"etl.gaps.blobs.cta": "Open Files",
|
||||||
"etl.gaps.jobs.title": "Processing history",
|
"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.body": "Earlier job history wasn't imported. 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.emptyHint": "Processing history wasn't imported — an empty list is expected.",
|
||||||
"etl.gaps.jobs.migratedHint": "{count} jobs were tagged migrated from an optional backfill.",
|
"etl.gaps.jobs.migratedHint": "{count} jobs were imported from the previous workspace.",
|
||||||
"etl.gaps.jobs.cta": "Open Processing",
|
"etl.gaps.jobs.cta": "Open Processing",
|
||||||
"etl.gaps.woo.title": "Store credentials",
|
"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.woo.cta": "Open Stores",
|
||||||
"etl.gaps.settings.title": "Company settings",
|
"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.",
|
"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.cancelledDesc": "El trabajo recién iniciado se canceló.",
|
||||||
"processing.receipt.cancelFailed": "No se pudo cancelar el trabajo de procesamiento",
|
"processing.receipt.cancelFailed": "No se pudo cancelar el trabajo de procesamiento",
|
||||||
"unsubscribe.title": "Cancelar suscripción",
|
"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.me": "Cancelar mi suscripción",
|
||||||
"unsubscribe.invalidLink": "Enlace no válido",
|
"unsubscribe.invalidLink": "Enlace no válido",
|
||||||
"unsubscribe.failedShort": "Error al cancelar la suscripción",
|
"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.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.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.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.eyebrow": "Precios",
|
||||||
"pricing.page.title": "Planes sencillos para catálogos en crecimiento",
|
"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.",
|
"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.subscribedMid": "o compara planes en",
|
||||||
"pricing.page.plansLink": "Planes",
|
"pricing.page.plansLink": "Planes",
|
||||||
"pricing.page.subscribedAfter": ".",
|
"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.lastUpdated": "Última actualización: {date}",
|
||||||
"legal.backHome": "↠Volver al inicio",
|
"legal.backHome": "↠Volver al inicio",
|
||||||
"legal.emailLabel": "Correo electrónico:",
|
"legal.emailLabel": "Correo electrónico:",
|
||||||
@@ -5065,7 +5072,7 @@ export const es: MessageDict = {
|
|||||||
"brand.uploadLogo": "Subir logo",
|
"brand.uploadLogo": "Subir logo",
|
||||||
"brand.clear": "Borrar",
|
"brand.clear": "Borrar",
|
||||||
"brand.field.logoUrl": "URL del logo",
|
"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.logoAlt": "Vista previa del logo de marca",
|
||||||
"brand.tipsTitle": "Consejos de fórmula / vista previa",
|
"brand.tipsTitle": "Consejos de fórmula / vista previa",
|
||||||
"brand.saving": "Guardando…",
|
"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.publicBefore": "Planes públicos: Free, Starter, Growth, Business y Enterprise. Crea una cuenta Free y luego mejora en",
|
||||||
"pricing.section.publicOr": "o",
|
"pricing.section.publicOr": "o",
|
||||||
"pricing.section.publicAfter": "mediante Stripe Checkout. Enterprise sigue siendo con ventas.",
|
"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.plansLink": "Planes",
|
||||||
"pricing.section.billingLink": "Facturación",
|
"pricing.section.billingLink": "Facturación",
|
||||||
"pricing.section.capabilitiesTitle": "Para qué está pensado cada plan",
|
"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.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.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.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.mostPopular": "Más popular",
|
||||||
"pricing.card.custom": "Personalizado",
|
"pricing.card.custom": "Personalizado",
|
||||||
"pricing.card.forever": "para siempre",
|
"pricing.card.forever": "para siempre",
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export const fr: MessageDict = {
|
|||||||
"auth.login.failed": "Échec de la connexion",
|
"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.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.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.yourEmail": "votre e-mail",
|
||||||
"auth.login.platformAdminsReissue": "Les administrateurs de la plateforme peuvent réémettre depuis",
|
"auth.login.platformAdminsReissue": "Les administrateurs de la plateforme peuvent réémettre depuis",
|
||||||
"auth.login.adminUsersLink": "Admin → Utilisateurs",
|
"auth.login.adminUsersLink": "Admin → Utilisateurs",
|
||||||
@@ -144,9 +144,9 @@ export const fr: MessageDict = {
|
|||||||
"auth.invite.acceptFailed": "Impossible d'accepter l'invitation",
|
"auth.invite.acceptFailed": "Impossible d'accepter l'invitation",
|
||||||
"auth.invite.setPasswordFailed": "Impossible de définir le mot de passe",
|
"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.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.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.adminUsersLink": "Admin → Utilisateurs",
|
||||||
"auth.invite.doneTitle": "Vous faites partie de l'équipe",
|
"auth.invite.doneTitle": "Vous faites partie de l'équipe",
|
||||||
"auth.invite.doneSetPasswordTitle": "Mot de passe enregistré",
|
"auth.invite.doneSetPasswordTitle": "Mot de passe enregistré",
|
||||||
@@ -163,7 +163,7 @@ export const fr: MessageDict = {
|
|||||||
"auth.invite.switchAccountTitle": "Changer de compte :",
|
"auth.invite.switchAccountTitle": "Changer de compte :",
|
||||||
"auth.invite.switchAccountBody": "déconnectez-vous, puis terminez ce formulaire avec l'e-mail invité{emailSuffix}.",
|
"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.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.switcher": "Langue de l'interface",
|
||||||
"locale.menu": "Choisir la langue de l'interface",
|
"locale.menu": "Choisir la langue de l'interface",
|
||||||
"locale.label": "Langue du tableau de bord",
|
"locale.label": "Langue du tableau de bord",
|
||||||
@@ -276,7 +276,7 @@ export const fr: MessageDict = {
|
|||||||
"settings.table.lastUsed": "Dernière utilisation",
|
"settings.table.lastUsed": "Dernière utilisation",
|
||||||
"dashboard.demoSandbox": "Bac àsable démo",
|
"dashboard.demoSandbox": "Bac àsable démo",
|
||||||
"dashboard.demoEmptyTitle": "Le bac àsable démo est vide",
|
"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.overviewTitle": "Vue d'ensemble",
|
||||||
"dashboard.quickLinks": "Liens rapides",
|
"dashboard.quickLinks": "Liens rapides",
|
||||||
"dashboard.whatsNew": "Nouveautés",
|
"dashboard.whatsNew": "Nouveautés",
|
||||||
@@ -319,7 +319,7 @@ export const fr: MessageDict = {
|
|||||||
"dashboard.workflow.exportMap": "Mapper puis exporter",
|
"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.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.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.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.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",
|
"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.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.focus.dismiss": "Fermer l’indication",
|
||||||
"activation.dismiss": "Masquer la checklist",
|
"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.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.cta": "Signaler des données manquantes ou incorrectes",
|
||||||
"hypercare.report.dismiss": "Ignorer",
|
"hypercare.report.dismiss": "Ignorer",
|
||||||
"hypercare.report.adminTriage": "Ouvrir la file hypercare",
|
"hypercare.report.adminTriage": "Ouvrir la file hypercare",
|
||||||
|
|
||||||
"etl.gaps.title": "What did not migrate",
|
"etl.gaps.title": "What still needs setup",
|
||||||
"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.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.dismiss": "Dismiss",
|
||||||
"etl.gaps.apiKeys.title": "API keys",
|
"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.apiKeys.cta": "Open API keys",
|
||||||
"etl.gaps.blobs.title": "File contents (blobs)",
|
"etl.gaps.blobs.title": "Uploaded files",
|
||||||
"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.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} file records are metadata-only (bytes were not migrated).",
|
"etl.gaps.blobs.countHint": "{count} files are listed without their contents (they weren't copied).",
|
||||||
"etl.gaps.blobs.cta": "Open Files",
|
"etl.gaps.blobs.cta": "Open Files",
|
||||||
"etl.gaps.jobs.title": "Processing history",
|
"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.body": "Earlier job history wasn't imported. 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.emptyHint": "Processing history wasn't imported — an empty list is expected.",
|
||||||
"etl.gaps.jobs.migratedHint": "{count} jobs were tagged migrated from an optional backfill.",
|
"etl.gaps.jobs.migratedHint": "{count} jobs were imported from the previous workspace.",
|
||||||
"etl.gaps.jobs.cta": "Open Processing",
|
"etl.gaps.jobs.cta": "Open Processing",
|
||||||
"etl.gaps.woo.title": "Store credentials",
|
"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.woo.cta": "Open Stores",
|
||||||
"etl.gaps.settings.title": "Company settings",
|
"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.",
|
"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.cancelledDesc": "La tâche qui vient de démarrer a été annulée.",
|
||||||
"processing.receipt.cancelFailed": "Impossible d'annuler la tâche de traitement",
|
"processing.receipt.cancelFailed": "Impossible d'annuler la tâche de traitement",
|
||||||
"unsubscribe.title": "Se désabonner",
|
"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.me": "Me désabonner",
|
||||||
"unsubscribe.invalidLink": "Lien invalide",
|
"unsubscribe.invalidLink": "Lien invalide",
|
||||||
"unsubscribe.failedShort": "Échec du désabonnement",
|
"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.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.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.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.eyebrow": "Tarifs",
|
||||||
"pricing.page.title": "Des offres simples pour des catalogues en croissance",
|
"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.",
|
"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.subscribedMid": "ou comparez les offres dans",
|
||||||
"pricing.page.plansLink": "Offres",
|
"pricing.page.plansLink": "Offres",
|
||||||
"pricing.page.subscribedAfter": ".",
|
"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.lastUpdated": "Dernière mise àjour : {date}",
|
||||||
"legal.backHome": "↠Retour àl’accueil",
|
"legal.backHome": "↠Retour àl’accueil",
|
||||||
"legal.emailLabel": "E-mail :",
|
"legal.emailLabel": "E-mail :",
|
||||||
@@ -5065,7 +5072,7 @@ export const fr: MessageDict = {
|
|||||||
"brand.uploadLogo": "Téléverser le logo",
|
"brand.uploadLogo": "Téléverser le logo",
|
||||||
"brand.clear": "Effacer",
|
"brand.clear": "Effacer",
|
||||||
"brand.field.logoUrl": "URL du logo",
|
"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.logoAlt": "Aperçu du logo de marque",
|
||||||
"brand.tipsTitle": "Conseils formule / aperçu",
|
"brand.tipsTitle": "Conseils formule / aperçu",
|
||||||
"brand.saving": "Enregistrement…",
|
"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.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.publicOr": "ou",
|
||||||
"pricing.section.publicAfter": "via Stripe Checkout. Enterprise reste géré par les ventes.",
|
"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.plansLink": "Offres",
|
||||||
"pricing.section.billingLink": "Facturation",
|
"pricing.section.billingLink": "Facturation",
|
||||||
"pricing.section.capabilitiesTitle": "Ce pour quoi chaque offre est conçue",
|
"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.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.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.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.mostPopular": "Le plus populaire",
|
||||||
"pricing.card.custom": "Sur mesure",
|
"pricing.card.custom": "Sur mesure",
|
||||||
"pricing.card.forever": "pour toujours",
|
"pricing.card.forever": "pour toujours",
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export const it: MessageDict = {
|
|||||||
"auth.login.failed": "Accesso non riuscito",
|
"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.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.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.yourEmail": "la tua email",
|
||||||
"auth.login.platformAdminsReissue": "Gli amministratori della piattaforma possono riemettere da",
|
"auth.login.platformAdminsReissue": "Gli amministratori della piattaforma possono riemettere da",
|
||||||
"auth.login.adminUsersLink": "Admin → Utenti",
|
"auth.login.adminUsersLink": "Admin → Utenti",
|
||||||
@@ -144,9 +144,9 @@ export const it: MessageDict = {
|
|||||||
"auth.invite.acceptFailed": "Impossibile accettare l'invito",
|
"auth.invite.acceptFailed": "Impossibile accettare l'invito",
|
||||||
"auth.invite.setPasswordFailed": "Impossibile impostare la password",
|
"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.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.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.adminUsersLink": "Admin → Utenti",
|
||||||
"auth.invite.doneTitle": "Fai parte del team",
|
"auth.invite.doneTitle": "Fai parte del team",
|
||||||
"auth.invite.doneSetPasswordTitle": "Password salvata",
|
"auth.invite.doneSetPasswordTitle": "Password salvata",
|
||||||
@@ -163,7 +163,7 @@ export const it: MessageDict = {
|
|||||||
"auth.invite.switchAccountTitle": "Cambia account:",
|
"auth.invite.switchAccountTitle": "Cambia account:",
|
||||||
"auth.invite.switchAccountBody": "esci, poi completa questo modulo con l'email invitata{emailSuffix}.",
|
"auth.invite.switchAccountBody": "esci, poi completa questo modulo con l'email invitata{emailSuffix}.",
|
||||||
"auth.invite.reissueTitle": "Percorso di riemissione:",
|
"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.switcher": "Lingua dell'interfaccia",
|
||||||
"locale.menu": "Scegli la lingua dell'interfaccia",
|
"locale.menu": "Scegli la lingua dell'interfaccia",
|
||||||
"locale.label": "Lingua della dashboard",
|
"locale.label": "Lingua della dashboard",
|
||||||
@@ -276,7 +276,7 @@ export const it: MessageDict = {
|
|||||||
"settings.table.lastUsed": "Ultimo utilizzo",
|
"settings.table.lastUsed": "Ultimo utilizzo",
|
||||||
"dashboard.demoSandbox": "Sandbox demo",
|
"dashboard.demoSandbox": "Sandbox demo",
|
||||||
"dashboard.demoEmptyTitle": "La sandbox demo è vuota",
|
"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.overviewTitle": "Panoramica",
|
||||||
"dashboard.quickLinks": "Collegamenti rapidi",
|
"dashboard.quickLinks": "Collegamenti rapidi",
|
||||||
"dashboard.whatsNew": "Novità",
|
"dashboard.whatsNew": "Novità",
|
||||||
@@ -319,7 +319,7 @@ export const it: MessageDict = {
|
|||||||
"dashboard.workflow.exportMap": "Mappa poi esporta",
|
"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.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.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.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.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",
|
"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.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.focus.dismiss": "Chiudi suggerimento",
|
||||||
"activation.dismiss": "Nascondi checklist",
|
"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.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.cta": "Segnala dati mancanti o errati",
|
||||||
"hypercare.report.dismiss": "Ignora",
|
"hypercare.report.dismiss": "Ignora",
|
||||||
"hypercare.report.adminTriage": "Apri coda hypercare",
|
"hypercare.report.adminTriage": "Apri coda hypercare",
|
||||||
|
|
||||||
"etl.gaps.title": "What did not migrate",
|
"etl.gaps.title": "What still needs setup",
|
||||||
"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.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.dismiss": "Dismiss",
|
||||||
"etl.gaps.apiKeys.title": "API keys",
|
"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.apiKeys.cta": "Open API keys",
|
||||||
"etl.gaps.blobs.title": "File contents (blobs)",
|
"etl.gaps.blobs.title": "Uploaded files",
|
||||||
"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.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} file records are metadata-only (bytes were not migrated).",
|
"etl.gaps.blobs.countHint": "{count} files are listed without their contents (they weren't copied).",
|
||||||
"etl.gaps.blobs.cta": "Open Files",
|
"etl.gaps.blobs.cta": "Open Files",
|
||||||
"etl.gaps.jobs.title": "Processing history",
|
"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.body": "Earlier job history wasn't imported. 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.emptyHint": "Processing history wasn't imported — an empty list is expected.",
|
||||||
"etl.gaps.jobs.migratedHint": "{count} jobs were tagged migrated from an optional backfill.",
|
"etl.gaps.jobs.migratedHint": "{count} jobs were imported from the previous workspace.",
|
||||||
"etl.gaps.jobs.cta": "Open Processing",
|
"etl.gaps.jobs.cta": "Open Processing",
|
||||||
"etl.gaps.woo.title": "Store credentials",
|
"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.woo.cta": "Open Stores",
|
||||||
"etl.gaps.settings.title": "Company settings",
|
"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.",
|
"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.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": "Solo gli amministratori dell'azienda possono {action}. Chiedi aiuto a un amministratore.",
|
||||||
"errors.companyAdminDenied.actionDefault": "fare questo",
|
"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.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 legacy non sono state migrate. Creane una con Use my API key o Impostazioni → Chiavi API  il secret è mostrato una sola volta.",
|
"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 legacy non sono state migrate  chiedi a un admin di crearne una, o incolla una chiave dk_.",
|
"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",
|
"errors.couldNotUpdateLanguage": "Impossibile aggiornare la lingua",
|
||||||
"toast.support.replyTitle": "Risposta del supporto",
|
"toast.support.replyTitle": "Risposta del supporto",
|
||||||
"toast.support.replyBody": "Lo staff ha risposto al tuo ticket di 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.cancelledDesc": "Il processo appena avviato è stato annullato.",
|
||||||
"processing.receipt.cancelFailed": "Impossibile annullare il processo",
|
"processing.receipt.cancelFailed": "Impossibile annullare il processo",
|
||||||
"unsubscribe.title": "Annulla iscrizione",
|
"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.me": "Annulla la mia iscrizione",
|
||||||
"unsubscribe.invalidLink": "Link non valido",
|
"unsubscribe.invalidLink": "Link non valido",
|
||||||
"unsubscribe.failedShort": "Annullamento iscrizione non riuscito",
|
"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.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.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.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.eyebrow": "Prezzi",
|
||||||
"pricing.page.title": "Piani semplici per cataloghi in crescita",
|
"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.",
|
"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.subscribedMid": "o confronta i piani in",
|
||||||
"pricing.page.plansLink": "Piani",
|
"pricing.page.plansLink": "Piani",
|
||||||
"pricing.page.subscribedAfter": ".",
|
"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.lastUpdated": "Ultimo aggiornamento: {date}",
|
||||||
"legal.backHome": "↠Torna alla home",
|
"legal.backHome": "↠Torna alla home",
|
||||||
"legal.emailLabel": "Email:",
|
"legal.emailLabel": "Email:",
|
||||||
@@ -5065,7 +5072,7 @@ export const it: MessageDict = {
|
|||||||
"brand.uploadLogo": "Carica logo",
|
"brand.uploadLogo": "Carica logo",
|
||||||
"brand.clear": "Cancella",
|
"brand.clear": "Cancella",
|
||||||
"brand.field.logoUrl": "URL del logo",
|
"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.logoAlt": "Anteprima logo del brand",
|
||||||
"brand.tipsTitle": "Suggerimenti formula / anteprima",
|
"brand.tipsTitle": "Suggerimenti formula / anteprima",
|
||||||
"brand.saving": "Salvataggio…",
|
"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.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.publicOr": "o",
|
||||||
"pricing.section.publicAfter": "tramite Stripe Checkout. Enterprise resta gestito dalle vendite.",
|
"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.plansLink": "Piani",
|
||||||
"pricing.section.billingLink": "Fatturazione",
|
"pricing.section.billingLink": "Fatturazione",
|
||||||
"pricing.section.capabilitiesTitle": "A cosa è pensato ogni piano",
|
"pricing.section.capabilitiesTitle": "A cosa è pensato ogni piano",
|
||||||
@@ -5291,7 +5300,7 @@ export const it: MessageDict = {
|
|||||||
"pricing.calc.enterpriseTitle": "Sounds like Enterprise",
|
"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.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.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.mostPopular": "Più popolare",
|
||||||
"pricing.card.custom": "Personalizzato",
|
"pricing.card.custom": "Personalizzato",
|
||||||
"pricing.card.forever": "per sempre",
|
"pricing.card.forever": "per sempre",
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export const ja: MessageDict = {
|
|||||||
"auth.login.failed": "ãƒÂグインã«失敗ã—ã¾ã—ãŸ",
|
"auth.login.failed": "ãƒÂグインã«失敗ã—ã¾ã—ãŸ",
|
||||||
"auth.login.passwordNotSet": "ã“ã®アカウントã«ã¯ã¾ã パスワードãŒ必è¦Âã§ã™。招待リンクを開ãÂÂã‹ã€Â管ç†者ã«å†Â発行をä¾Âé ¼ã—ã¦ãÂÂã ã•ã„。",
|
"auth.login.passwordNotSet": "ã“ã®アカウントã«ã¯ã¾ã パスワードãŒ必è¦Âã§ã™。招待リンクを開ãÂÂã‹ã€Â管ç†者ã«å†Â発行をä¾Âé ¼ã—ã¦ãÂÂã ã•ã„。",
|
||||||
"auth.login.setPasswordFirstTitle": "å…ˆã«パスワードをè¨Â定:",
|
"auth.login.setPasswordFirstTitle": "å…ˆã«パスワードをè¨Â定:",
|
||||||
"auth.login.setPasswordFirstBody": "メールã®招待リンクを使çâ€Â¨Ã£Â—ã¦ãÂÂã ã•ã„。リンクãÂŒå¤ã„アドレスã«é€ÂられãŸ場åˆ(メール変更)ã€Â会社ã®管ç†者ã« {email} å‘ã‘ã®パスワードè¨Â定招待ã®å†Â発行をä¾Âé ¼ã—ã¦ãÂÂã ã•ã„。",
|
"auth.login.setPasswordFirstBody": "メールの招待リンクを使用してください。新しいリンクが必要な場合は、会社の管理者に {email} 向けのパスワード設定リンクの再発行を依頼してください。",
|
||||||
"auth.login.yourEmail": "ã‚ãªãŸã®メール",
|
"auth.login.yourEmail": "ã‚ãªãŸã®メール",
|
||||||
"auth.login.platformAdminsReissue": "プラットフォーム管ç†者ã¯次ã‹らå†Â発行ã§ãÂÂã¾ã™:",
|
"auth.login.platformAdminsReissue": "プラットフォーム管ç†者ã¯次ã‹らå†Â発行ã§ãÂÂã¾ã™:",
|
||||||
"auth.login.adminUsersLink": "管ç†→ ユーザー",
|
"auth.login.adminUsersLink": "管ç†→ ユーザー",
|
||||||
@@ -144,9 +144,9 @@ export const ja: MessageDict = {
|
|||||||
"auth.invite.acceptFailed": "招待を承èªÂã§ãÂÂã¾ã›んã§ã—ãŸ",
|
"auth.invite.acceptFailed": "招待を承èªÂã§ãÂÂã¾ã›んã§ã—ãŸ",
|
||||||
"auth.invite.setPasswordFailed": "パスワードをè¨Â定ã§ãÂÂã¾ã›んã§ã—ãŸ",
|
"auth.invite.setPasswordFailed": "パスワードをè¨Â定ã§ãÂÂã¾ã›んã§ã—ãŸ",
|
||||||
"auth.invite.expired": "ã“ã®招待ã¯無åйã¾ãŸã¯期é™Â切れã§ã™。会社ã®管ç†者ã«新ã—ã„招待ã®é€Âä¿¡ã‚’ä¾Âé ¼ã—ã€Âæ–°ã—ã„リンクを開ãÂÂã‹(下ã«新ã—ã„トークンを貼り付ã‘ã¦ãÂÂã ã•ã„)。",
|
"auth.invite.expired": "ã“ã®招待ã¯無åйã¾ãŸã¯期é™Â切れã§ã™。会社ã®管ç†者ã«新ã—ã„招待ã®é€Âä¿¡ã‚’ä¾Âé ¼ã—ã€Âæ–°ã—ã„リンクを開ãÂÂã‹(下ã«新ã—ã„トークンを貼り付ã‘ã¦ãÂÂã ã•ã„)。",
|
||||||
"auth.invite.setPasswordExpired": "ã“ã®パスワードè¨Â定リンクã¯無åйã¾ãŸã¯期é™Â切れã§ã™。会社ã¾ãŸã¯プラットフォームã®管ç†者ã«å†Â発行をä¾Âé ¼ã—ã€Âæ–°ã—ã„リンクを開ãÂÂã‹(下ã«新ã—ã„トークンを貼り付ã‘ã¦ãÂÂã ã•ã„)。",
|
"auth.invite.setPasswordExpired": "このパスワード設定リンクは無効または期限切れです。会社の管理者にこのメール向けのパスワード設定リンクの再発行を依頼し、新しいリンクを開くか(下に新しいトークンを貼り付けてください)。",
|
||||||
"auth.invite.emailMismatchDefault": "ã“ã®招待ã¨ã¯別ã®メールアドレスã§ãƒÂグインã—ã¦ã„ã¾ã™。",
|
"auth.invite.emailMismatchDefault": "ã“ã®招待ã¨ã¯別ã®メールアドレスã§ãƒÂグインã—ã¦ã„ã¾ã™。",
|
||||||
"auth.invite.expiredFooter": "期é™Â切れã®リンクã§ã™ã‹?管ç†者ã«å†Â発行をä¾Âé ¼ã—ã¦ãÂÂã ã•ã„  セルフサービスã®å†Âé€Âä¿¡APIã¯ã‚りã¾ã›ん。プラットフォーム管ç†者:",
|
"auth.invite.expiredFooter": "期限切れのリンクですか?会社の管理者に再発行を依頼してください — セルフサービスの再送信APIはありません。",
|
||||||
"auth.invite.adminUsersLink": "管ç†→ ユーザー",
|
"auth.invite.adminUsersLink": "管ç†→ ユーザー",
|
||||||
"auth.invite.doneTitle": "ãƒÂームã«å‚加ã—ã¾ã—ãŸ",
|
"auth.invite.doneTitle": "ãƒÂームã«å‚加ã—ã¾ã—ãŸ",
|
||||||
"auth.invite.doneSetPasswordTitle": "パスワードをä¿Âå˜ã—ã¾ã—ãŸ",
|
"auth.invite.doneSetPasswordTitle": "パスワードをä¿Âå˜ã—ã¾ã—ãŸ",
|
||||||
@@ -163,7 +163,7 @@ export const ja: MessageDict = {
|
|||||||
"auth.invite.switchAccountTitle": "アカウント切替:",
|
"auth.invite.switchAccountTitle": "アカウント切替:",
|
||||||
"auth.invite.switchAccountBody": "ãƒÂグアウトã—ã¦ã‹らã€Â招待ã•れãŸメール{emailSuffix}ã§ã“ã®フォームを完了ã—ã¦ãÂÂã ã•ã„。",
|
"auth.invite.switchAccountBody": "ãƒÂグアウトã—ã¦ã‹らã€Â招待ã•れãŸメール{emailSuffix}ã§ã“ã®フォームを完了ã—ã¦ãÂÂã ã•ã„。",
|
||||||
"auth.invite.reissueTitle": "å†Â発行ã®手順:",
|
"auth.invite.reissueTitle": "å†Â発行ã®手順:",
|
||||||
"auth.invite.reissueBody": "実際ã®ãƒÂグインçâ€Â¨Ã£Æ’¡ãƒ¼ãƒ«ãŒ変ã‚Âã£ãŸ場åˆ(メール変更)ã€Â会社ã®管ç†者ã«ã“ã®招待ã®å–り消ã—ã¨ã€ÂãƒÂグインã«使ã†メールã¸ã®新ã—ã„招待é€Âä¿¡ã‚’ä¾Âé ¼ã—ã¦ãÂÂã ã•ã„。プラットフォーム管ç†者ã¯「管ç†→ ユーザーã€Âã‹らパスワードè¨Â定リンクもå†Â発行ã§ãÂÂã¾ã™。",
|
"auth.invite.reissueBody": "会社の管理者にこのメール向けのパスワード設定リンクの再発行を依頼するか、この招待を取り消してログインに使うメールへ新しい招待を送るよう依頼してください。",
|
||||||
"locale.switcher": "インターフェース言語",
|
"locale.switcher": "インターフェース言語",
|
||||||
"locale.menu": "インターフェース言語をé¸択",
|
"locale.menu": "インターフェース言語をé¸択",
|
||||||
"locale.label": "ダッシュボードã®言語",
|
"locale.label": "ダッシュボードã®言語",
|
||||||
@@ -276,7 +276,7 @@ export const ja: MessageDict = {
|
|||||||
"settings.table.lastUsed": "最終使çâ€Â¨",
|
"settings.table.lastUsed": "最終使çâ€Â¨",
|
||||||
"dashboard.demoSandbox": "デモサンドボックス",
|
"dashboard.demoSandbox": "デモサンドボックス",
|
||||||
"dashboard.demoEmptyTitle": "デモサンドボックスã¯空ã§ãÂâ„¢",
|
"dashboard.demoEmptyTitle": "デモサンドボックスã¯空ã§ãÂâ„¢",
|
||||||
"dashboard.demoEmptyHint": "デモサンドボックスã¯空ã§ãÂâ„¢  A1ã«切り替ãˆるã‹フィードを接続ã—ã¦実際ã®カタãƒÂグ統計を表示ã—ã¾ã™。",
|
"dashboard.demoEmptyHint": "デモサンドボックスは空です — フィードを接続して実際のカタログ統計を表示します。",
|
||||||
"dashboard.overviewTitle": "概è¦Â",
|
"dashboard.overviewTitle": "概è¦Â",
|
||||||
"dashboard.quickLinks": "クイックリンク",
|
"dashboard.quickLinks": "クイックリンク",
|
||||||
"dashboard.whatsNew": "æ–°ç€情報",
|
"dashboard.whatsNew": "æ–°ç€情報",
|
||||||
@@ -319,7 +319,7 @@ export const ja: MessageDict = {
|
|||||||
"dashboard.workflow.exportMap": "マップã—ã¦エクスãƒÂート",
|
"dashboard.workflow.exportMap": "マップã—ã¦エクスãƒÂート",
|
||||||
"dashboard.workflowHint": "ã‚«ã‚¿ãƒÂã‚°å–込 → 充実 / 処ç†→ エクスãƒÂートã¾ãŸã¯ストアã¸プッシュ。{name} ã®次ã®ステップã¸。",
|
"dashboard.workflowHint": "ã‚«ã‚¿ãƒÂã‚°å–込 → 充実 / 処ç†→ エクスãƒÂートã¾ãŸã¯ストアã¸プッシュ。{name} ã®次ã®ステップã¸。",
|
||||||
"dashboard.overviewHint": "{name} ã®リアルタイムåˆ計",
|
"dashboard.overviewHint": "{name} ã®リアルタイムåˆ計",
|
||||||
"dashboard.demoEmptyMessage": "ヘッダーã§A1(ã¾ãŸã¯別ã®シード済ã¿会社)ã«切り替ãˆるã‹ã€Âã“ã“ã§フィードを接続ã—ã¦サンドボックスã«データを入れã¾ã™。",
|
"dashboard.demoEmptyMessage": "ここでフィードを接続してサンドボックスにデータを入れるか、ヘッダーで別の会社に切り替えます。",
|
||||||
"dashboard.emptyTitle": "ã‚«ã‚¿ãƒÂã‚°ã‚’å–り込む",
|
"dashboard.emptyTitle": "ã‚«ã‚¿ãƒÂã‚°ã‚’å–り込む",
|
||||||
"dashboard.emptyMessage": "ã‚«ã‚¿ãƒÂã‚°å–込 → 充実 / 処ç†→ エクスãƒÂートã¾ãŸã¯ストアã¸プッシュ。下ã®「ã¯ã˜ã‚Âã«ã€Âã‹ら開始(ã¾ãŸã¯フィード接続 / CSVアップãƒÂード)。",
|
"dashboard.emptyMessage": "ã‚«ã‚¿ãƒÂã‚°å–込 → 充実 / 処ç†→ エクスãƒÂートã¾ãŸã¯ストアã¸プッシュ。下ã®「ã¯ã˜ã‚Âã«ã€Âã‹ら開始(ã¾ãŸã¯フィード接続 / CSVアップãƒÂード)。",
|
||||||
"dashboard.connectFeedAnyway": "ãÂÂれã§もフィードを接続",
|
"dashboard.connectFeedAnyway": "ãÂÂれã§もフィードを接続",
|
||||||
@@ -367,29 +367,29 @@ export const ja: MessageDict = {
|
|||||||
"activation.focus.sync": "次ã¸: マッãƒâ€Ã£Æ’³ã‚°ä¿Âå˜後ã«サンプルをåŒ期ã—ã¾ã™。マップ済ã¿フィードã§ Sync を使ã„ã€Â未マップã®ソースã¯é¿ã‘ã¦ãÂÂã ã•ã„。",
|
"activation.focus.sync": "次ã¸: マッãƒâ€Ã£Æ’³ã‚°ä¿Âå˜後ã«サンプルをåŒ期ã—ã¾ã™。マップ済ã¿フィードã§ Sync を使ã„ã€Â未マップã®ソースã¯é¿ã‘ã¦ãÂÂã ã•ã„。",
|
||||||
"activation.focus.dismiss": "ヒントを閉ã˜る",
|
"activation.focus.dismiss": "ヒントを閉ã˜る",
|
||||||
"activation.dismiss": "ãƒÂェックリストを閉ã˜る",
|
"activation.dismiss": "ãƒÂェックリストを閉ã˜る",
|
||||||
"hypercare.report.title": "カットオーãƒÂー hypercare",
|
"hypercare.report.title": "移行後のサポートが必要ですか?",
|
||||||
"hypercare.report.message": "移行後ã«データã®欠è½や誤りãÂ΋‚りã¾ã™ã‹?報告ã„ãŸã ã‘れã°ワークスペースを修æÂ£ã—ã¾ã™。",
|
"hypercare.report.message": "移行後ã«データã®欠è½や誤りãÂ΋‚りã¾ã™ã‹?報告ã„ãŸã ã‘れã°ワークスペースを修æÂ£ã—ã¾ã™。",
|
||||||
"hypercare.report.cta": "欠è½・誤ã£ãŸデータを報告",
|
"hypercare.report.cta": "欠è½・誤ã£ãŸデータを報告",
|
||||||
"hypercare.report.dismiss": "é–‰ã˜る",
|
"hypercare.report.dismiss": "é–‰ã˜る",
|
||||||
"hypercare.report.adminTriage": "hypercareã‚Âューを開ãÂÂ",
|
"hypercare.report.adminTriage": "hypercareã‚Âューを開ãÂÂ",
|
||||||
|
|
||||||
"etl.gaps.title": "What did not migrate",
|
"etl.gaps.title": "What still needs setup",
|
||||||
"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.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.dismiss": "Dismiss",
|
||||||
"etl.gaps.apiKeys.title": "API keys",
|
"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.apiKeys.cta": "Open API keys",
|
||||||
"etl.gaps.blobs.title": "File contents (blobs)",
|
"etl.gaps.blobs.title": "Uploaded files",
|
||||||
"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.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} file records are metadata-only (bytes were not migrated).",
|
"etl.gaps.blobs.countHint": "{count} files are listed without their contents (they weren't copied).",
|
||||||
"etl.gaps.blobs.cta": "Open Files",
|
"etl.gaps.blobs.cta": "Open Files",
|
||||||
"etl.gaps.jobs.title": "Processing history",
|
"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.body": "Earlier job history wasn't imported. 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.emptyHint": "Processing history wasn't imported — an empty list is expected.",
|
||||||
"etl.gaps.jobs.migratedHint": "{count} jobs were tagged migrated from an optional backfill.",
|
"etl.gaps.jobs.migratedHint": "{count} jobs were imported from the previous workspace.",
|
||||||
"etl.gaps.jobs.cta": "Open Processing",
|
"etl.gaps.jobs.cta": "Open Processing",
|
||||||
"etl.gaps.woo.title": "Store credentials",
|
"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.woo.cta": "Open Stores",
|
||||||
"etl.gaps.settings.title": "Company settings",
|
"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.",
|
"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.cancelledDesc": "開始直後ã®ジョブãÂŒã‚Âャンセルã•れã¾ã—ãŸ。",
|
||||||
"processing.receipt.cancelFailed": "処ç†ジョブをã‚Âャンセルã§ãÂÂã¾ã›んã§ã—ãŸ",
|
"processing.receipt.cancelFailed": "処ç†ジョブをã‚Âャンセルã§ãÂÂã¾ã›んã§ã—ãŸ",
|
||||||
"unsubscribe.title": "é…Âä¿¡åÂϾ¢",
|
"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.me": "é…Âä¿¡ã‚’åÂϾ¢ã™る",
|
||||||
"unsubscribe.invalidLink": "無効ãªリンク",
|
"unsubscribe.invalidLink": "無効ãªリンク",
|
||||||
"unsubscribe.failedShort": "é…Âä¿¡åÂϾ¢ã«失敗ã—ã¾ã—ãŸ",
|
"unsubscribe.failedShort": "é…Âä¿¡åÂϾ¢ã«失敗ã—ã¾ã—ãŸ",
|
||||||
@@ -4727,6 +4728,10 @@ export const ja: MessageDict = {
|
|||||||
"seo.terms.description": "Descrybe ã®利çâ€Â¨Ã¦Â¡ä»¶ï¼šãƒ•ィードã®インãƒÂートã€Âã‚«ã‚¿ãƒÂã‚°ã®エンリッãƒÂメントã€ÂAI æâ€Â¯Ã¦Â´ã‚³ãƒ³ãƒ†ãƒ³ãƒ„ã€ÂエクスãƒÂートã€ÂãŠよã³ビジãƒÂスå‘ã‘ WooCommerce åŒ期。",
|
"seo.terms.description": "Descrybe ã®利çâ€Â¨Ã¦Â¡ä»¶ï¼šãƒ•ィードã®インãƒÂートã€Âã‚«ã‚¿ãƒÂã‚°ã®エンリッãƒÂメントã€ÂAI æâ€Â¯Ã¦Â´ã‚³ãƒ³ãƒ†ãƒ³ãƒ„ã€ÂエクスãƒÂートã€ÂãŠよã³ビジãƒÂスå‘ã‘ WooCommerce åŒ期。",
|
||||||
"seo.features.title": "機能  フィードã€ÂエンリッãƒÂメントã€ÂエクスãƒÂート | Descrybe",
|
"seo.features.title": "機能  フィードã€ÂエンリッãƒÂメントã€ÂエクスãƒÂート | Descrybe",
|
||||||
"seo.features.description": "Descrybe ãŒサプライヤーフィードをå–り込ã¿ã€Âフィールドをタクソノミーã«マッãƒâ€Ã£Æ’³ã‚°ã—ã€Â商å“ÂデータをエンリッãƒÂã—ã€ÂエクスãƒÂートフィードã€ÂWooCommerceã€Âã¾ãŸã¯ API ã§カタãƒÂã‚°ã‚’é…Âä¿¡ã™る仕組ã¿をãÂâ€Ã§Â¢ÂºÃ¨ÂªÂãÂÂã ã•ã„。",
|
"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.eyebrow": "料金",
|
||||||
"pricing.page.title": "æˆÂé•·ã™るカタãƒÂã‚°å‘ã‘ã®シンプルãªプラン",
|
"pricing.page.title": "æˆÂé•·ã™るカタãƒÂã‚°å‘ã‘ã®シンプルãªプラン",
|
||||||
"pricing.page.lead": "フィードã®マッãƒâ€Ã£Æ’³ã‚°ã¨基本的ãª整形ã‹ら無料ã§始ã‚Âられã¾ã™。AI ã«よるタイトルï¼Â説明ã€Â商å“Âæ•°ã®増加ã€ÂエクスãƒÂートフィードã€ÂWooCommerce 連æÂºãŒ必è¦Âã«ãªã£ãŸらアップグレード  Starter ã‹ら Enterprise ã¾ã§。",
|
"pricing.page.lead": "フィードã®マッãƒâ€Ã£Æ’³ã‚°ã¨基本的ãª整形ã‹ら無料ã§始ã‚Âられã¾ã™。AI ã«よるタイトルï¼Â説明ã€Â商å“Âæ•°ã®増加ã€ÂエクスãƒÂートフィードã€ÂWooCommerce 連æÂºãŒ必è¦Âã«ãªã£ãŸらアップグレード  Starter ã‹ら Enterprise ã¾ã§。",
|
||||||
@@ -4734,6 +4739,8 @@ export const ja: MessageDict = {
|
|||||||
"pricing.page.subscribedMid": "ã€Âプランæ¯â€Ã¨Â¼Æ’ã¯",
|
"pricing.page.subscribedMid": "ã€Âプランæ¯â€Ã¨Â¼Æ’ã¯",
|
||||||
"pricing.page.plansLink": "プラン",
|
"pricing.page.plansLink": "プラン",
|
||||||
"pricing.page.subscribedAfter": "ã¸。",
|
"pricing.page.subscribedAfter": "ã¸。",
|
||||||
|
"pricing.page.guestSubscribedBefore": "Already subscribed?",
|
||||||
|
"pricing.page.guestSubscribedAfter": "to manage billing and compare plans.",
|
||||||
"legal.lastUpdated": "最終更新日: {date}",
|
"legal.lastUpdated": "最終更新日: {date}",
|
||||||
"legal.backHome": "↠ホームã«戻る",
|
"legal.backHome": "↠ホームã«戻る",
|
||||||
"legal.emailLabel": "メール:",
|
"legal.emailLabel": "メール:",
|
||||||
@@ -5065,7 +5072,7 @@ export const ja: MessageDict = {
|
|||||||
"brand.uploadLogo": "ãƒÂゴをアップãƒÂード",
|
"brand.uploadLogo": "ãƒÂゴをアップãƒÂード",
|
||||||
"brand.clear": "クリア",
|
"brand.clear": "クリア",
|
||||||
"brand.field.logoUrl": "ãƒÂã‚´ URL",
|
"brand.field.logoUrl": "ãƒÂã‚´ URL",
|
||||||
"brand.placeholder.logoUrl": "https://… ã¾ãŸã¯アップãƒÂード済ã¿ /api/brand/logo/files/…",
|
"brand.placeholder.logoUrl": "https://…",
|
||||||
"brand.logoAlt": "ブランドãƒÂã‚´ã®プレビュー",
|
"brand.logoAlt": "ブランドãƒÂã‚´ã®プレビュー",
|
||||||
"brand.tipsTitle": "æ•°å¼ / プレビューã®ヒント",
|
"brand.tipsTitle": "æ•°å¼ / プレビューã®ヒント",
|
||||||
"brand.saving": "ä¿Âå˜ä¸Â…",
|
"brand.saving": "ä¿Âå˜ä¸Â…",
|
||||||
@@ -5226,6 +5233,8 @@ export const ja: MessageDict = {
|
|||||||
"pricing.section.publicBefore": "公開プラン:Freeã€ÂStarterã€ÂGrowthã€ÂBusinessã€ÂEnterprise。Freeアカウントを作æˆÂã—ã€ÂãÂÂã®後",
|
"pricing.section.publicBefore": "公開プラン:Freeã€ÂStarterã€ÂGrowthã€ÂBusinessã€ÂEnterprise。Freeアカウントを作æˆÂã—ã€ÂãÂÂã®後",
|
||||||
"pricing.section.publicOr": "ã¾ãŸã¯",
|
"pricing.section.publicOr": "ã¾ãŸã¯",
|
||||||
"pricing.section.publicAfter": "ã§Stripe Checkout経çâ€Â±Ã£Â§ã‚¢ãƒƒãƒ—グレード。Enterpriseã¯セールス主導ã®ã¾ã¾ã§ã™。",
|
"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.plansLink": "プラン",
|
||||||
"pricing.section.billingLink": "請求",
|
"pricing.section.billingLink": "請求",
|
||||||
"pricing.section.capabilitiesTitle": "å„プランãŒ想定ã™るçâ€Â¨Ã©â‚¬â€",
|
"pricing.section.capabilitiesTitle": "å„プランãŒ想定ã™るçâ€Â¨Ã©â‚¬â€",
|
||||||
@@ -5291,7 +5300,7 @@ export const ja: MessageDict = {
|
|||||||
"pricing.calc.enterpriseTitle": "Sounds like Enterprise",
|
"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.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.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.mostPopular": "一番人気",
|
||||||
"pricing.card.custom": "カスタム",
|
"pricing.card.custom": "カスタム",
|
||||||
"pricing.card.forever": "ãšã£ã¨",
|
"pricing.card.forever": "ãšã£ã¨",
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export const nl: MessageDict = {
|
|||||||
"auth.login.failed": "Inloggen mislukt",
|
"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.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.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.yourEmail": "uw e-mail",
|
||||||
"auth.login.platformAdminsReissue": "Platformbeheerders kunnen opnieuw uitgeven via",
|
"auth.login.platformAdminsReissue": "Platformbeheerders kunnen opnieuw uitgeven via",
|
||||||
"auth.login.adminUsersLink": "Admin → Gebruikers",
|
"auth.login.adminUsersLink": "Admin → Gebruikers",
|
||||||
@@ -144,9 +144,9 @@ export const nl: MessageDict = {
|
|||||||
"auth.invite.acceptFailed": "Uitnodiging kon niet worden geaccepteerd",
|
"auth.invite.acceptFailed": "Uitnodiging kon niet worden geaccepteerd",
|
||||||
"auth.invite.setPasswordFailed": "Wachtwoord kon niet worden ingesteld",
|
"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.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.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.adminUsersLink": "Admin → Gebruikers",
|
||||||
"auth.invite.doneTitle": "U bent in het team",
|
"auth.invite.doneTitle": "U bent in het team",
|
||||||
"auth.invite.doneSetPasswordTitle": "Wachtwoord opgeslagen",
|
"auth.invite.doneSetPasswordTitle": "Wachtwoord opgeslagen",
|
||||||
@@ -163,7 +163,7 @@ export const nl: MessageDict = {
|
|||||||
"auth.invite.switchAccountTitle": "Account wisselen:",
|
"auth.invite.switchAccountTitle": "Account wisselen:",
|
||||||
"auth.invite.switchAccountBody": "log uit en voltooi dit formulier met het uitgenodigde e-mailadres{emailSuffix}.",
|
"auth.invite.switchAccountBody": "log uit en voltooi dit formulier met het uitgenodigde e-mailadres{emailSuffix}.",
|
||||||
"auth.invite.reissueTitle": "Pad voor opnieuw uitgeven:",
|
"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.switcher": "Interfacetaal",
|
||||||
"locale.menu": "Kies interfacetaal",
|
"locale.menu": "Kies interfacetaal",
|
||||||
"locale.label": "Dashboardtaal",
|
"locale.label": "Dashboardtaal",
|
||||||
@@ -276,7 +276,7 @@ export const nl: MessageDict = {
|
|||||||
"settings.table.lastUsed": "Laatst gebruikt",
|
"settings.table.lastUsed": "Laatst gebruikt",
|
||||||
"dashboard.demoSandbox": "Demo-sandbox",
|
"dashboard.demoSandbox": "Demo-sandbox",
|
||||||
"dashboard.demoEmptyTitle": "Demo-sandbox is leeg",
|
"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.overviewTitle": "Overzicht",
|
||||||
"dashboard.quickLinks": "Snelle links",
|
"dashboard.quickLinks": "Snelle links",
|
||||||
"dashboard.whatsNew": "Wat is nieuw",
|
"dashboard.whatsNew": "Wat is nieuw",
|
||||||
@@ -319,7 +319,7 @@ export const nl: MessageDict = {
|
|||||||
"dashboard.workflow.exportMap": "Mappen en exporteren",
|
"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.workflowHint": "Catalogus in → verrijken / verwerken → exporteren of pushen naar winkels. Ga naar de volgende stap voor {name}.",
|
||||||
"dashboard.overviewHint": "Live totalen 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.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.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",
|
"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.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.focus.dismiss": "Hint sluiten",
|
||||||
"activation.dismiss": "Checklist verbergen",
|
"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.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.cta": "Ontbrekende of verkeerde data melden",
|
||||||
"hypercare.report.dismiss": "Sluiten",
|
"hypercare.report.dismiss": "Sluiten",
|
||||||
"hypercare.report.adminTriage": "Hypercare-wachtrij openen",
|
"hypercare.report.adminTriage": "Hypercare-wachtrij openen",
|
||||||
|
|
||||||
"etl.gaps.title": "What did not migrate",
|
"etl.gaps.title": "What still needs setup",
|
||||||
"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.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.dismiss": "Dismiss",
|
||||||
"etl.gaps.apiKeys.title": "API keys",
|
"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.apiKeys.cta": "Open API keys",
|
||||||
"etl.gaps.blobs.title": "File contents (blobs)",
|
"etl.gaps.blobs.title": "Uploaded files",
|
||||||
"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.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} file records are metadata-only (bytes were not migrated).",
|
"etl.gaps.blobs.countHint": "{count} files are listed without their contents (they weren't copied).",
|
||||||
"etl.gaps.blobs.cta": "Open Files",
|
"etl.gaps.blobs.cta": "Open Files",
|
||||||
"etl.gaps.jobs.title": "Processing history",
|
"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.body": "Earlier job history wasn't imported. 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.emptyHint": "Processing history wasn't imported — an empty list is expected.",
|
||||||
"etl.gaps.jobs.migratedHint": "{count} jobs were tagged migrated from an optional backfill.",
|
"etl.gaps.jobs.migratedHint": "{count} jobs were imported from the previous workspace.",
|
||||||
"etl.gaps.jobs.cta": "Open Processing",
|
"etl.gaps.jobs.cta": "Open Processing",
|
||||||
"etl.gaps.woo.title": "Store credentials",
|
"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.woo.cta": "Open Stores",
|
||||||
"etl.gaps.settings.title": "Company settings",
|
"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.",
|
"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.readOnly": "Het systeem staat in alleen-lezenmodus. Wijzigingen zijn tijdelijk uitgeschakeld.",
|
||||||
"errors.companyAdminDenied": "Alleen bedrijfsbeheerders kunnen {action}. Vraag een beheerder om hulp.",
|
"errors.companyAdminDenied": "Alleen bedrijfsbeheerders kunnen {action}. Vraag een beheerder om hulp.",
|
||||||
"errors.companyAdminDenied.actionDefault": "dit doen",
|
"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.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": "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.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. Legacy-sleutels niet gemigreerd  vraag een admin er een aan te maken, of plak een dk_-sleutel.",
|
"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",
|
"errors.couldNotUpdateLanguage": "Taal kon niet worden bijgewerkt",
|
||||||
"toast.support.replyTitle": "Supportantwoord",
|
"toast.support.replyTitle": "Supportantwoord",
|
||||||
"toast.support.replyBody": "Medewerkers hebben gereageerd op uw supportticket.",
|
"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.cancelledDesc": "De zojuist gestarte taak is geannuleerd.",
|
||||||
"processing.receipt.cancelFailed": "Kon verwerkingstaak niet annuleren",
|
"processing.receipt.cancelFailed": "Kon verwerkingstaak niet annuleren",
|
||||||
"unsubscribe.title": "Uitschrijven",
|
"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.me": "Mij uitschrijven",
|
||||||
"unsubscribe.invalidLink": "Ongeldige link",
|
"unsubscribe.invalidLink": "Ongeldige link",
|
||||||
"unsubscribe.failedShort": "Uitschrijven mislukt",
|
"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.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.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.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.eyebrow": "Prijzen",
|
||||||
"pricing.page.title": "Eenvoudige plannen voor groeiende catalogi",
|
"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.",
|
"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.subscribedMid": "of vergelijk plannen in",
|
||||||
"pricing.page.plansLink": "Plannen",
|
"pricing.page.plansLink": "Plannen",
|
||||||
"pricing.page.subscribedAfter": ".",
|
"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.lastUpdated": "Laatst bijgewerkt: {date}",
|
||||||
"legal.backHome": "↠Terug naar home",
|
"legal.backHome": "↠Terug naar home",
|
||||||
"legal.emailLabel": "E-mail:",
|
"legal.emailLabel": "E-mail:",
|
||||||
@@ -5065,7 +5072,7 @@ export const nl: MessageDict = {
|
|||||||
"brand.uploadLogo": "Logo uploaden",
|
"brand.uploadLogo": "Logo uploaden",
|
||||||
"brand.clear": "Wissen",
|
"brand.clear": "Wissen",
|
||||||
"brand.field.logoUrl": "Logo-URL",
|
"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.logoAlt": "Voorbeeld van merklogo",
|
||||||
"brand.tipsTitle": "Formule- / voorbeeldtips",
|
"brand.tipsTitle": "Formule- / voorbeeldtips",
|
||||||
"brand.saving": "Opslaan…",
|
"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.publicBefore": "Publieke plannen: Free, Starter, Growth, Business en Enterprise. Maak een Free-account aan en upgrade daarna onder",
|
||||||
"pricing.section.publicOr": "of",
|
"pricing.section.publicOr": "of",
|
||||||
"pricing.section.publicAfter": "via Stripe Checkout. Enterprise blijft sales-gestuurd.",
|
"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.plansLink": "Plannen",
|
||||||
"pricing.section.billingLink": "Facturering",
|
"pricing.section.billingLink": "Facturering",
|
||||||
"pricing.section.capabilitiesTitle": "Waarvoor elk plan is gebouwd",
|
"pricing.section.capabilitiesTitle": "Waarvoor elk plan is gebouwd",
|
||||||
@@ -5291,7 +5300,7 @@ export const nl: MessageDict = {
|
|||||||
"pricing.calc.enterpriseTitle": "Sounds like Enterprise",
|
"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.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.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.mostPopular": "Meest populair",
|
||||||
"pricing.card.custom": "Op maat",
|
"pricing.card.custom": "Op maat",
|
||||||
"pricing.card.forever": "voor altijd",
|
"pricing.card.forever": "voor altijd",
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export const pl: MessageDict = {
|
|||||||
"auth.login.failed": "Logowanie nie powiodło się",
|
"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.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.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.yourEmail": "twój e-mail",
|
||||||
"auth.login.platformAdminsReissue": "Administratorzy platformy mogą ponownie wystawić z",
|
"auth.login.platformAdminsReissue": "Administratorzy platformy mogą ponownie wystawić z",
|
||||||
"auth.login.adminUsersLink": "Admin → Użytkownicy",
|
"auth.login.adminUsersLink": "Admin → Użytkownicy",
|
||||||
@@ -144,9 +144,9 @@ export const pl: MessageDict = {
|
|||||||
"auth.invite.acceptFailed": "Nie można zaakceptować zaproszenia",
|
"auth.invite.acceptFailed": "Nie można zaakceptować zaproszenia",
|
||||||
"auth.invite.setPasswordFailed": "Nie można ustawić hasła",
|
"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.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.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.adminUsersLink": "Admin → Użytkownicy",
|
||||||
"auth.invite.doneTitle": "JesteÅ› w zespole",
|
"auth.invite.doneTitle": "JesteÅ› w zespole",
|
||||||
"auth.invite.doneSetPasswordTitle": "Hasło zapisane",
|
"auth.invite.doneSetPasswordTitle": "Hasło zapisane",
|
||||||
@@ -163,7 +163,7 @@ export const pl: MessageDict = {
|
|||||||
"auth.invite.switchAccountTitle": "Zmień konto:",
|
"auth.invite.switchAccountTitle": "Zmień konto:",
|
||||||
"auth.invite.switchAccountBody": "wyloguj się, a następnie dokończ ten formularz zaproszonym e-mailem{emailSuffix}.",
|
"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.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.switcher": "Język interfejsu",
|
||||||
"locale.menu": "Wybierz język interfejsu",
|
"locale.menu": "Wybierz język interfejsu",
|
||||||
"locale.label": "Język panelu",
|
"locale.label": "Język panelu",
|
||||||
@@ -276,7 +276,7 @@ export const pl: MessageDict = {
|
|||||||
"settings.table.lastUsed": "Ostatnio użyty",
|
"settings.table.lastUsed": "Ostatnio użyty",
|
||||||
"dashboard.demoSandbox": "Piaskownica demo",
|
"dashboard.demoSandbox": "Piaskownica demo",
|
||||||
"dashboard.demoEmptyTitle": "Piaskownica demo jest pusta",
|
"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.overviewTitle": "PrzeglÄ…d",
|
||||||
"dashboard.quickLinks": "Szybkie linki",
|
"dashboard.quickLinks": "Szybkie linki",
|
||||||
"dashboard.whatsNew": "Co nowego",
|
"dashboard.whatsNew": "Co nowego",
|
||||||
@@ -319,7 +319,7 @@ export const pl: MessageDict = {
|
|||||||
"dashboard.workflow.exportMap": "Mapuj, potem eksportuj",
|
"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.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.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.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.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",
|
"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.sync": "Dalej: zsynchronizuj próbkę po zapisaniu mapowania. Użyj Sync na zmapowanym feedzie  unikaj niezmapowanych źródeł.",
|
||||||
"activation.focus.dismiss": "Zamknij podpowiedź",
|
"activation.focus.dismiss": "Zamknij podpowiedź",
|
||||||
"activation.dismiss": "Ukryj listÄ™",
|
"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.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.cta": "Zgłoś brakujące lub błędne dane",
|
||||||
"hypercare.report.dismiss": "Odrzuć",
|
"hypercare.report.dismiss": "Odrzuć",
|
||||||
"hypercare.report.adminTriage": "Otwórz kolejkę hypercare",
|
"hypercare.report.adminTriage": "Otwórz kolejkę hypercare",
|
||||||
|
|
||||||
"etl.gaps.title": "What did not migrate",
|
"etl.gaps.title": "What still needs setup",
|
||||||
"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.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.dismiss": "Dismiss",
|
||||||
"etl.gaps.apiKeys.title": "API keys",
|
"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.apiKeys.cta": "Open API keys",
|
||||||
"etl.gaps.blobs.title": "File contents (blobs)",
|
"etl.gaps.blobs.title": "Uploaded files",
|
||||||
"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.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} file records are metadata-only (bytes were not migrated).",
|
"etl.gaps.blobs.countHint": "{count} files are listed without their contents (they weren't copied).",
|
||||||
"etl.gaps.blobs.cta": "Open Files",
|
"etl.gaps.blobs.cta": "Open Files",
|
||||||
"etl.gaps.jobs.title": "Processing history",
|
"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.body": "Earlier job history wasn't imported. 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.emptyHint": "Processing history wasn't imported — an empty list is expected.",
|
||||||
"etl.gaps.jobs.migratedHint": "{count} jobs were tagged migrated from an optional backfill.",
|
"etl.gaps.jobs.migratedHint": "{count} jobs were imported from the previous workspace.",
|
||||||
"etl.gaps.jobs.cta": "Open Processing",
|
"etl.gaps.jobs.cta": "Open Processing",
|
||||||
"etl.gaps.woo.title": "Store credentials",
|
"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.woo.cta": "Open Stores",
|
||||||
"etl.gaps.settings.title": "Company settings",
|
"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.",
|
"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.cancelledDesc": "Właśnie uruchomione zadanie zostało anulowane.",
|
||||||
"processing.receipt.cancelFailed": "Nie można anulować zadania przetwarzania",
|
"processing.receipt.cancelFailed": "Nie można anulować zadania przetwarzania",
|
||||||
"unsubscribe.title": "Wypisz siÄ™",
|
"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.me": "Wypisz mnie",
|
||||||
"unsubscribe.invalidLink": "Nieprawidłowy link",
|
"unsubscribe.invalidLink": "Nieprawidłowy link",
|
||||||
"unsubscribe.failedShort": "Wypisanie nie powiodło się",
|
"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.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.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.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.eyebrow": "Cennik",
|
||||||
"pricing.page.title": "Proste plany dla rosnących katalogów",
|
"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.",
|
"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.subscribedMid": "lub porównaj plany w",
|
||||||
"pricing.page.plansLink": "Plany",
|
"pricing.page.plansLink": "Plany",
|
||||||
"pricing.page.subscribedAfter": ".",
|
"pricing.page.subscribedAfter": ".",
|
||||||
|
"pricing.page.guestSubscribedBefore": "Masz juz subskrypcje?",
|
||||||
|
"pricing.page.guestSubscribedAfter": "aby zarzadzac platnosciami i porownac plany.",
|
||||||
"legal.lastUpdated": "Ostatnia aktualizacja: {date}",
|
"legal.lastUpdated": "Ostatnia aktualizacja: {date}",
|
||||||
"legal.backHome": "↠Powrót do strony głównej",
|
"legal.backHome": "↠Powrót do strony głównej",
|
||||||
"legal.emailLabel": "E-mail:",
|
"legal.emailLabel": "E-mail:",
|
||||||
@@ -5065,7 +5072,7 @@ export const pl: MessageDict = {
|
|||||||
"brand.uploadLogo": "Prześlij logo",
|
"brand.uploadLogo": "Prześlij logo",
|
||||||
"brand.clear": "Wyczyść",
|
"brand.clear": "Wyczyść",
|
||||||
"brand.field.logoUrl": "URL logo",
|
"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.logoAlt": "PodglÄ…d logo marki",
|
||||||
"brand.tipsTitle": "Wskazówki formuły / podglądu",
|
"brand.tipsTitle": "Wskazówki formuły / podglądu",
|
||||||
"brand.saving": "Zapisywanie…",
|
"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.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.publicOr": "lub",
|
||||||
"pricing.section.publicAfter": "przez Stripe Checkout. Enterprise pozostaje obsługiwany przez sprzedaż.",
|
"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.plansLink": "Plany",
|
||||||
"pricing.section.billingLink": "Płatności",
|
"pricing.section.billingLink": "Płatności",
|
||||||
"pricing.section.capabilitiesTitle": "Do czego jest stworzony każdy plan",
|
"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.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.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.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.mostPopular": "Najpopularniejszy",
|
||||||
"pricing.card.custom": "Indywidualny",
|
"pricing.card.custom": "Indywidualny",
|
||||||
"pricing.card.forever": "na zawsze",
|
"pricing.card.forever": "na zawsze",
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export const pt: MessageDict = {
|
|||||||
"auth.login.failed": "Falha no inÃÂcio de sessão",
|
"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.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.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.yourEmail": "o seu e-mail",
|
||||||
"auth.login.platformAdminsReissue": "Os administradores da plataforma podem reemitir a partir de",
|
"auth.login.platformAdminsReissue": "Os administradores da plataforma podem reemitir a partir de",
|
||||||
"auth.login.adminUsersLink": "Admin → Utilizadores",
|
"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.acceptFailed": "Não foi possÃÂvel aceitar o convite",
|
||||||
"auth.invite.setPasswordFailed": "Não foi possÃÂvel definir a palavra-passe",
|
"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.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.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.adminUsersLink": "Admin → Utilizadores",
|
||||||
"auth.invite.doneTitle": "Já faz parte da equipa",
|
"auth.invite.doneTitle": "Já faz parte da equipa",
|
||||||
"auth.invite.doneSetPasswordTitle": "Palavra-passe guardada",
|
"auth.invite.doneSetPasswordTitle": "Palavra-passe guardada",
|
||||||
@@ -163,7 +163,7 @@ export const pt: MessageDict = {
|
|||||||
"auth.invite.switchAccountTitle": "Mudar de conta:",
|
"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.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.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.switcher": "Idioma da interface",
|
||||||
"locale.menu": "Escolher idioma da interface",
|
"locale.menu": "Escolher idioma da interface",
|
||||||
"locale.label": "Idioma do painel",
|
"locale.label": "Idioma do painel",
|
||||||
@@ -276,7 +276,7 @@ export const pt: MessageDict = {
|
|||||||
"settings.table.lastUsed": "Última utilização",
|
"settings.table.lastUsed": "Última utilização",
|
||||||
"dashboard.demoSandbox": "Sandbox de demonstração",
|
"dashboard.demoSandbox": "Sandbox de demonstração",
|
||||||
"dashboard.demoEmptyTitle": "A sandbox de demonstração está vazia",
|
"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.overviewTitle": "Visão geral",
|
||||||
"dashboard.quickLinks": "Ligações rápidas",
|
"dashboard.quickLinks": "Ligações rápidas",
|
||||||
"dashboard.whatsNew": "Novidades",
|
"dashboard.whatsNew": "Novidades",
|
||||||
@@ -319,7 +319,7 @@ export const pt: MessageDict = {
|
|||||||
"dashboard.workflow.exportMap": "Mapear e exportar",
|
"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.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.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.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.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",
|
"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.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.focus.dismiss": "Fechar dica",
|
||||||
"activation.dismiss": "Dispensar lista",
|
"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.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.cta": "Reportar dados em falta ou incorretos",
|
||||||
"hypercare.report.dismiss": "Dispensar",
|
"hypercare.report.dismiss": "Dispensar",
|
||||||
"hypercare.report.adminTriage": "Abrir fila de hypercare",
|
"hypercare.report.adminTriage": "Abrir fila de hypercare",
|
||||||
|
|
||||||
"etl.gaps.title": "What did not migrate",
|
"etl.gaps.title": "What still needs setup",
|
||||||
"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.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.dismiss": "Dismiss",
|
||||||
"etl.gaps.apiKeys.title": "API keys",
|
"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.apiKeys.cta": "Open API keys",
|
||||||
"etl.gaps.blobs.title": "File contents (blobs)",
|
"etl.gaps.blobs.title": "Uploaded files",
|
||||||
"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.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} file records are metadata-only (bytes were not migrated).",
|
"etl.gaps.blobs.countHint": "{count} files are listed without their contents (they weren't copied).",
|
||||||
"etl.gaps.blobs.cta": "Open Files",
|
"etl.gaps.blobs.cta": "Open Files",
|
||||||
"etl.gaps.jobs.title": "Processing history",
|
"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.body": "Earlier job history wasn't imported. 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.emptyHint": "Processing history wasn't imported — an empty list is expected.",
|
||||||
"etl.gaps.jobs.migratedHint": "{count} jobs were tagged migrated from an optional backfill.",
|
"etl.gaps.jobs.migratedHint": "{count} jobs were imported from the previous workspace.",
|
||||||
"etl.gaps.jobs.cta": "Open Processing",
|
"etl.gaps.jobs.cta": "Open Processing",
|
||||||
"etl.gaps.woo.title": "Store credentials",
|
"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.woo.cta": "Open Stores",
|
||||||
"etl.gaps.settings.title": "Company settings",
|
"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.",
|
"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.cancelledDesc": "O trabalho recém-iniciado foi cancelado.",
|
||||||
"processing.receipt.cancelFailed": "Não foi possÃÂvel cancelar o trabalho de processamento",
|
"processing.receipt.cancelFailed": "Não foi possÃÂvel cancelar o trabalho de processamento",
|
||||||
"unsubscribe.title": "Cancelar subscrição",
|
"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.me": "Cancelar a minha subscrição",
|
||||||
"unsubscribe.invalidLink": "Ligação inválida",
|
"unsubscribe.invalidLink": "Ligação inválida",
|
||||||
"unsubscribe.failedShort": "Falha ao cancelar a subscrição",
|
"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.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.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.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.eyebrow": "Preços",
|
||||||
"pricing.page.title": "Planos simples para catálogos em crescimento",
|
"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.",
|
"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.subscribedMid": "ou comparar planos em",
|
||||||
"pricing.page.plansLink": "Planos",
|
"pricing.page.plansLink": "Planos",
|
||||||
"pricing.page.subscribedAfter": ".",
|
"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.lastUpdated": "Última atualização: {date}",
|
||||||
"legal.backHome": "↠Voltar ao inÃÂcio",
|
"legal.backHome": "↠Voltar ao inÃÂcio",
|
||||||
"legal.emailLabel": "E-mail:",
|
"legal.emailLabel": "E-mail:",
|
||||||
@@ -5065,7 +5072,7 @@ export const pt: MessageDict = {
|
|||||||
"brand.uploadLogo": "Carregar logótipo",
|
"brand.uploadLogo": "Carregar logótipo",
|
||||||
"brand.clear": "Limpar",
|
"brand.clear": "Limpar",
|
||||||
"brand.field.logoUrl": "URL do logótipo",
|
"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.logoAlt": "Pré-visualização do logótipo da marca",
|
||||||
"brand.tipsTitle": "Dicas de fórmula / pré-visualização",
|
"brand.tipsTitle": "Dicas de fórmula / pré-visualização",
|
||||||
"brand.saving": "A guardar…",
|
"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.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.publicOr": "ou",
|
||||||
"pricing.section.publicAfter": "via Stripe Checkout. O Enterprise continua a ser gerido pelas vendas.",
|
"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.plansLink": "Planos",
|
||||||
"pricing.section.billingLink": "Faturação",
|
"pricing.section.billingLink": "Faturação",
|
||||||
"pricing.section.capabilitiesTitle": "Para que está pensado cada plano",
|
"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.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.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.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.mostPopular": "Mais popular",
|
||||||
"pricing.card.custom": "Personalizado",
|
"pricing.card.custom": "Personalizado",
|
||||||
"pricing.card.forever": "para sempre",
|
"pricing.card.forever": "para sempre",
|
||||||
|
|||||||
@@ -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 { isHttpError } from "@sveltejs/kit";
|
||||||
import type { RequestEvent } from "@sveltejs/kit";
|
import type { RequestEvent } from "@sveltejs/kit";
|
||||||
import {
|
import {
|
||||||
|
adminLoginRedirect,
|
||||||
assertSameOrigin,
|
assertSameOrigin,
|
||||||
|
fetchMeStaff,
|
||||||
|
isAdminBootstrapPath,
|
||||||
|
isAdminSupportPath,
|
||||||
isFullPlatformAdmin,
|
isFullPlatformAdmin,
|
||||||
requirePlatformAdminServer
|
isSupportDesk,
|
||||||
|
requirePlatformAdminServer,
|
||||||
|
requireSupportDeskServer
|
||||||
} from "./require-platform-admin.ts";
|
} from "./require-platform-admin.ts";
|
||||||
|
|
||||||
function makeEvent(opts: {
|
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", () => {
|
describe("assertSameOrigin", () => {
|
||||||
it("allows matching Origin", () => {
|
it("allows matching Origin", () => {
|
||||||
assertSameOrigin(
|
assertSameOrigin(
|
||||||
|
|||||||
@@ -7,39 +7,90 @@ import { error } from "@sveltejs/kit";
|
|||||||
import type { RequestEvent } from "@sveltejs/kit";
|
import type { RequestEvent } from "@sveltejs/kit";
|
||||||
import {
|
import {
|
||||||
isFullPlatformAdmin,
|
isFullPlatformAdmin,
|
||||||
|
isSupportDesk,
|
||||||
type StaffAccessMe
|
type StaffAccessMe
|
||||||
} from "../staff-access.ts";
|
} from "../staff-access.ts";
|
||||||
|
|
||||||
export { isFullPlatformAdmin } from "../staff-access.ts";
|
export { isFullPlatformAdmin, isSupportDesk } from "../staff-access.ts";
|
||||||
|
|
||||||
type MeStaff = StaffAccessMe;
|
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. */
|
/** Absolute /api/auth/me when PUBLIC_API_URL is set (Compose/adapter-node); else Vite same-origin proxy. */
|
||||||
function authMeUrl(): string {
|
function authMeUrl(): string {
|
||||||
const base = (process.env.PUBLIC_API_URL ?? "").replace(/\/$/, "");
|
const base = (process.env.PUBLIC_API_URL ?? "").replace(/\/$/, "");
|
||||||
return base ? `${base}/api/auth/me` : "/api/auth/me";
|
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.
|
* Fetch /api/auth/me without throwing. Layout uses this to redirect 401 → login.
|
||||||
* Uses event.fetch so session cookies reach the Go /api/auth/me (Vite proxy or PUBLIC_API_URL).
|
* 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 headers: Record<string, string> = { Accept: "application/json" };
|
||||||
const cookie = event.request.headers.get("cookie");
|
const cookie = event.request.headers.get("cookie");
|
||||||
if (cookie) headers.cookie = cookie;
|
if (cookie) headers.cookie = cookie;
|
||||||
const res = await event.fetch(authMeUrl(), { headers });
|
const res = await event.fetch(authMeUrl(), { headers });
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
error(401, "Authentication required.");
|
return { ok: false, status: 401 };
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
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;
|
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.");
|
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 {
|
function originOf(url: string): string | null {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import { isFullPlatformAdmin, shouldUnlockAllFeatures } from "./staff-access.ts";
|
import { isFullPlatformAdmin, isSupportDesk, shouldUnlockAllFeatures } from "./staff-access.ts";
|
||||||
|
|
||||||
describe("isFullPlatformAdmin", () => {
|
describe("isFullPlatformAdmin", () => {
|
||||||
it("allows staff_access.full_admin", () => {
|
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", () => {
|
describe("shouldUnlockAllFeatures", () => {
|
||||||
it("unlocks Demo / full admin", () => {
|
it("unlocks Demo / full admin", () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ export function isFullPlatformAdmin(me: StaffAccessMe | null | undefined): boole
|
|||||||
return Boolean(me.user?.is_platform_admin);
|
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.
|
* Nav / shell preview unlock for Demo + platform staff.
|
||||||
* Must stay false for normal A1 (and other tenant) users — even if a stale
|
* Must stay false for normal A1 (and other tenant) users — even if a stale
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/state";
|
|
||||||
import { api, ApiError } from "$lib/api";
|
import { api, ApiError } from "$lib/api";
|
||||||
|
import { consumeAuthLinkSecrets } from "$lib/auth-link-token";
|
||||||
import { trackEvent } from "$lib/analytics";
|
import { trackEvent } from "$lib/analytics";
|
||||||
import { writeActivationProgress } from "$lib/activation";
|
import { writeActivationProgress } from "$lib/activation";
|
||||||
import { i18n } from "$lib/i18n";
|
import { i18n } from "$lib/i18n";
|
||||||
@@ -27,12 +27,11 @@
|
|||||||
mismatch: boolean;
|
mismatch: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialToken = page.url.searchParams.get("token") ?? "";
|
let token = $state("");
|
||||||
const initialMode = page.url.searchParams.get("mode");
|
/** True when the token arrived via hash or query link; never show it in a visible input. */
|
||||||
|
let tokenFromLink = $state(false);
|
||||||
let token = $state(initialToken);
|
/** Fragment tokens are client-only; wait for mount before treating the token as missing. */
|
||||||
/** True when the token arrived via ?token=; never show it in a visible input. */
|
let hydrated = $state(false);
|
||||||
let tokenFromLink = $state(initialToken.length > 0);
|
|
||||||
let name = $state("");
|
let name = $state("");
|
||||||
let password = $state("");
|
let password = $state("");
|
||||||
let error = $state("");
|
let error = $state("");
|
||||||
@@ -44,9 +43,7 @@
|
|||||||
let sessionEmail = $state("");
|
let sessionEmail = $state("");
|
||||||
let signingOut = $state(false);
|
let signingOut = $state(false);
|
||||||
/** Admin HMAC emails use mode=set-password; migrator invites use the invite token path. */
|
/** Admin HMAC emails use mode=set-password; migrator invites use the invite token path. */
|
||||||
let mode = $state<"invite" | "set-password">(
|
let mode = $state<"invite" | "set-password">("invite");
|
||||||
initialMode === "set-password" ? "set-password" : "invite"
|
|
||||||
);
|
|
||||||
const errorId = "accept-invite-form-error";
|
const errorId = "accept-invite-form-error";
|
||||||
const passwordHintId = "accept-invite-password-hint";
|
const passwordHintId = "accept-invite-password-hint";
|
||||||
|
|
||||||
@@ -103,12 +100,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!tokenFromLink) return;
|
const consumed = consumeAuthLinkSecrets(window.location.href);
|
||||||
// Drop the secret from the address bar / history (referrer + shoulder-surf).
|
if (consumed.token) {
|
||||||
const cleaned = new URL(window.location.href);
|
token = consumed.token;
|
||||||
cleaned.searchParams.delete("token");
|
tokenFromLink = true;
|
||||||
history.replaceState(history.state, "", cleaned.pathname + cleaned.search + cleaned.hash);
|
}
|
||||||
void loadPreview(token);
|
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) {
|
async function onSubmit(event: Event) {
|
||||||
@@ -179,7 +185,15 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card>
|
<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>
|
<CardHeader>
|
||||||
<CardTitle level={1}>
|
<CardTitle level={1}>
|
||||||
{mode === "set-password"
|
{mode === "set-password"
|
||||||
@@ -377,9 +391,6 @@
|
|||||||
</p>
|
</p>
|
||||||
<p class="mt-1.5 text-center text-xs text-text-muted">
|
<p class="mt-1.5 text-center text-xs text-text-muted">
|
||||||
{i18n.t("auth.invite.expiredFooter")}
|
{i18n.t("auth.invite.expiredFooter")}
|
||||||
<a href="/admin/users" class="font-medium text-link hover:underline"
|
|
||||||
>{i18n.t("auth.invite.adminUsersLink")}</a
|
|
||||||
>.
|
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
{/if}
|
{/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 } };
|
||||||
|
};
|
||||||
@@ -264,18 +264,18 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageShell
|
{#if loading}
|
||||||
eyebrow={i18n.t("admin.overview.eyebrow")}
|
<Spinner />
|
||||||
title={i18n.t("admin.overview.title")}
|
{:else if accessDenied}
|
||||||
description={i18n.t("admin.overview.description")}
|
<ForbiddenEmptyState kind="platform" />
|
||||||
>
|
{:else if error}
|
||||||
{#if loading}
|
<Alert message={error} />
|
||||||
<Spinner />
|
{:else}
|
||||||
{:else if accessDenied}
|
<PageShell
|
||||||
<ForbiddenEmptyState kind="platform" />
|
eyebrow={i18n.t("admin.overview.eyebrow")}
|
||||||
{:else if error}
|
title={i18n.t("admin.overview.title")}
|
||||||
<Alert message={error} />
|
description={i18n.t("admin.overview.description")}
|
||||||
{:else}
|
>
|
||||||
{#if summary}
|
{#if summary}
|
||||||
<section class="space-y-3" aria-labelledby="admin-kpi-heading">
|
<section class="space-y-3" aria-labelledby="admin-kpi-heading">
|
||||||
<div class="flex flex-wrap items-end justify-between gap-2">
|
<div class="flex flex-wrap items-end justify-between gap-2">
|
||||||
@@ -517,5 +517,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
</PageShell>
|
||||||
</PageShell>
|
{/if}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
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 type { MeResponse } from "$lib/types";
|
||||||
import PageShell from "$lib/components/PageShell.svelte";
|
import PageShell from "$lib/components/PageShell.svelte";
|
||||||
import Spinner from "$lib/components/Spinner.svelte";
|
import Spinner from "$lib/components/Spinner.svelte";
|
||||||
@@ -23,33 +24,33 @@
|
|||||||
let me = $state<MeResponse | null>(null);
|
let me = $state<MeResponse | null>(null);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
const gate = await requirePlatformAdmin();
|
||||||
me = await api<MeResponse>("/api/auth/me");
|
if (!gate.ok) {
|
||||||
} catch (err) {
|
if (gate.reason === "auth") {
|
||||||
if (isUnauthorized(err)) {
|
|
||||||
await goto("/login");
|
await goto("/login");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isForbidden(err)) {
|
accessDenied = true;
|
||||||
accessDenied = true;
|
if (gate.reason !== "forbidden") {
|
||||||
return;
|
error = gate.message;
|
||||||
}
|
}
|
||||||
error = failureMessage(err, i18n.t("admin.bootstrap.loadFailed"));
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
loading = false;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
me = gate.me;
|
||||||
|
loading = false;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageShell
|
{#if loading}
|
||||||
title={i18n.t("admin.bootstrap.title")}
|
<Spinner />
|
||||||
description={i18n.t("admin.bootstrap.description")}
|
{:else if accessDenied}
|
||||||
>
|
<ForbiddenEmptyState kind="platform" />
|
||||||
{#if loading}
|
{:else}
|
||||||
<Spinner />
|
<PageShell
|
||||||
{:else if accessDenied}
|
title={i18n.t("admin.bootstrap.title")}
|
||||||
<ForbiddenEmptyState kind="platform" />
|
description={i18n.t("admin.bootstrap.description")}
|
||||||
{:else}
|
>
|
||||||
<Card class="mx-auto max-w-md">
|
<Card class="mx-auto max-w-md">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{i18n.t("admin.bootstrap.statusTitle")}</CardTitle>
|
<CardTitle>{i18n.t("admin.bootstrap.statusTitle")}</CardTitle>
|
||||||
@@ -58,11 +59,11 @@
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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">
|
<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="font-medium">{i18n.t("admin.bootstrap.alreadyAdmin")}</p>
|
||||||
<p class="mt-2 text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
@@ -81,7 +82,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter class="flex justify-between gap-2">
|
<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>
|
<Button class="w-full" onclick={() => goto("/admin")}>{i18n.t("admin.bootstrap.goToPanel")}</Button>
|
||||||
{:else}
|
{:else}
|
||||||
<Button variant="outline" class="w-full" onclick={() => goto("/dashboard")}>
|
<Button variant="outline" class="w-full" onclick={() => goto("/dashboard")}>
|
||||||
@@ -90,5 +91,5 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</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");
|
||||||
|
};
|
||||||
@@ -528,7 +528,7 @@
|
|||||||
const link =
|
const link =
|
||||||
res.accept_url?.trim() ||
|
res.accept_url?.trim() ||
|
||||||
(res.token
|
(res.token
|
||||||
? `${window.location.origin}/accept-invite?token=${encodeURIComponent(res.token)}`
|
? `${window.location.origin}/accept-invite#token=${encodeURIComponent(res.token)}`
|
||||||
: "");
|
: "");
|
||||||
if (link) {
|
if (link) {
|
||||||
inviteAcceptLink = link;
|
inviteAcceptLink = link;
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
import { DEMO_BOOKING_URL } from "$lib/site";
|
import { DEMO_BOOKING_URL } from "$lib/site";
|
||||||
import { submitSalesContact } from "$lib/sales-contact";
|
import { submitSalesContact } from "$lib/sales-contact";
|
||||||
import { notifySuccess, notifyApiError } from "$lib/notify";
|
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 Alert from "$lib/components/Alert.svelte";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -65,7 +67,19 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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}
|
{#if done}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -152,4 +166,6 @@
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
{/if}
|
{/if}
|
||||||
</PageShell>
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
loadDocsAuthStatus,
|
loadDocsAuthStatus,
|
||||||
maskKeyPrefix,
|
maskKeyPrefix,
|
||||||
readStoredTryItKey,
|
readStoredTryItKey,
|
||||||
|
relabelRapiDocAuthRequired,
|
||||||
resolveDocsTryItKey,
|
resolveDocsTryItKey,
|
||||||
writeStoredTryItKey,
|
writeStoredTryItKey,
|
||||||
type DocsAuthStatus,
|
type DocsAuthStatus,
|
||||||
@@ -54,6 +55,7 @@
|
|||||||
let runtimeWarning = $state("");
|
let runtimeWarning = $state("");
|
||||||
/** $state so theme $effect re-runs after mount + SiteHeader toggle. */
|
/** $state so theme $effect re-runs after mount + SiteHeader toggle. */
|
||||||
let rapiEl = $state<RapiDocElement | undefined>(undefined);
|
let rapiEl = $state<RapiDocElement | undefined>(undefined);
|
||||||
|
let authLabelObserver: MutationObserver | null = null;
|
||||||
let askOpen = $state(false);
|
let askOpen = $state(false);
|
||||||
let mountGeneration = 0;
|
let mountGeneration = 0;
|
||||||
let hydrateCheckTimer: number | undefined;
|
let hydrateCheckTimer: number | undefined;
|
||||||
@@ -121,6 +123,8 @@
|
|||||||
window.clearTimeout(hydrateCheckTimer);
|
window.clearTimeout(hydrateCheckTimer);
|
||||||
window.removeEventListener("error", onWindowError);
|
window.removeEventListener("error", onWindowError);
|
||||||
window.removeEventListener("unhandledrejection", onRejection);
|
window.removeEventListener("unhandledrejection", onRejection);
|
||||||
|
authLabelObserver?.disconnect();
|
||||||
|
authLabelObserver = null;
|
||||||
rapiEl?.remove();
|
rapiEl?.remove();
|
||||||
rapiEl = undefined;
|
rapiEl = undefined;
|
||||||
};
|
};
|
||||||
@@ -323,6 +327,8 @@
|
|||||||
loadError = "";
|
loadError = "";
|
||||||
runtimeWarning = "";
|
runtimeWarning = "";
|
||||||
window.clearTimeout(hydrateCheckTimer);
|
window.clearTimeout(hydrateCheckTimer);
|
||||||
|
authLabelObserver?.disconnect();
|
||||||
|
authLabelObserver = null;
|
||||||
rapiEl?.remove();
|
rapiEl?.remove();
|
||||||
rapiEl = undefined;
|
rapiEl = undefined;
|
||||||
|
|
||||||
@@ -338,6 +344,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
rapiEl = instance;
|
rapiEl = instance;
|
||||||
|
authLabelObserver = relabelRapiDocAuthRequired(instance);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (gen !== mountGeneration) return;
|
if (gen !== mountGeneration) return;
|
||||||
loadState = "error";
|
loadState = "error";
|
||||||
@@ -533,7 +540,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{: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
|
{docsAuth.canCreateKey
|
||||||
? i18n.t("docs.tryIt.noKeysLegacyAdmin")
|
? i18n.t("docs.tryIt.noKeysLegacyAdmin")
|
||||||
: i18n.t("docs.tryIt.noKeysLegacyMember")}
|
: i18n.t("docs.tryIt.noKeysLegacyMember")}
|
||||||
|
|||||||
@@ -470,7 +470,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="custom_model">{i18n.t("ai.field.model")}</Label>
|
<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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -494,7 +494,7 @@
|
|||||||
id="api_key"
|
id="api_key"
|
||||||
type="password"
|
type="password"
|
||||||
bind:value={apiKey}
|
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"
|
autocomplete="new-password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -95,12 +95,6 @@
|
|||||||
email: email || i18n.t("auth.login.yourEmail")
|
email: email || i18n.t("auth.login.yourEmail")
|
||||||
})}
|
})}
|
||||||
</p>
|
</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>
|
<p>
|
||||||
<a href="/accept-invite" class="font-medium text-link hover:underline"
|
<a href="/accept-invite" class="font-medium text-link hover:underline"
|
||||||
>{i18n.t("auth.login.haveToken")}</a
|
>{i18n.t("auth.login.haveToken")}</a
|
||||||
|
|||||||
@@ -28,14 +28,9 @@
|
|||||||
</p>
|
</p>
|
||||||
<MarketingAuthCtas class="mt-8" />
|
<MarketingAuthCtas class="mt-8" />
|
||||||
<p class="mt-4 text-sm text-text-muted">
|
<p class="mt-4 text-sm text-text-muted">
|
||||||
{i18n.t("pricing.page.subscribedBefore")}
|
{i18n.t("pricing.page.guestSubscribedBefore")}
|
||||||
<a href="/billing" class="font-medium text-link hover:underline"
|
<a href="/login" class="font-medium text-link hover:underline">{i18n.t("site.logIn")}</a>
|
||||||
>{i18n.t("nav.billing")}</a
|
{i18n.t("pricing.page.guestSubscribedAfter")}
|
||||||
>
|
|
||||||
{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")}
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
<PricingSection hideIntro />
|
<PricingSection hideIntro />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { page } from "$app/state";
|
|
||||||
import { api, ApiError } from "$lib/api";
|
import { api, ApiError } from "$lib/api";
|
||||||
|
import { consumeAuthLinkSecrets } from "$lib/auth-link-token";
|
||||||
import { apiFormError, fieldDescribedBy, fieldInvalid } from "$lib/api-form-error";
|
import { apiFormError, fieldDescribedBy, fieldInvalid } from "$lib/api-form-error";
|
||||||
import { i18n } from "$lib/i18n";
|
import { i18n } from "$lib/i18n";
|
||||||
import {
|
import {
|
||||||
@@ -17,12 +17,6 @@
|
|||||||
Label
|
Label
|
||||||
} from "$lib/components/ui";
|
} 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("");
|
let token = $state("");
|
||||||
/** True when the token arrived via link; never show it in a visible input. */
|
/** True when the token arrived via link; never show it in a visible input. */
|
||||||
let tokenFromLink = $state(false);
|
let tokenFromLink = $state(false);
|
||||||
@@ -39,20 +33,15 @@
|
|||||||
const passwordHintId = "reset-password-hint";
|
const passwordHintId = "reset-password-hint";
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const fromHash = tokenFromHash(window.location.hash);
|
const consumed = consumeAuthLinkSecrets(window.location.href);
|
||||||
const fromQuery = (new URL(window.location.href).searchParams.get("token") ?? "").trim();
|
if (consumed.token) {
|
||||||
const fromSsrQuery = (page.url.searchParams.get("token") ?? "").trim();
|
token = consumed.token;
|
||||||
const resolved = fromHash || fromQuery || fromSsrQuery;
|
|
||||||
if (resolved) {
|
|
||||||
token = resolved;
|
|
||||||
tokenFromLink = true;
|
tokenFromLink = true;
|
||||||
}
|
}
|
||||||
hydrated = true;
|
hydrated = true;
|
||||||
if (!fromHash && !fromQuery && !fromSsrQuery) return;
|
if (consumed.stripped) {
|
||||||
const cleaned = new URL(window.location.href);
|
history.replaceState(history.state, "", consumed.cleanedPathSearch);
|
||||||
cleaned.searchParams.delete("token");
|
}
|
||||||
cleaned.hash = "";
|
|
||||||
history.replaceState(history.state, "", cleaned.pathname + cleaned.search);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onSubmit(event: Event) {
|
async function onSubmit(event: Event) {
|
||||||
|
|||||||
@@ -598,7 +598,7 @@
|
|||||||
const link =
|
const link =
|
||||||
created.accept_url?.trim() ||
|
created.accept_url?.trim() ||
|
||||||
(created.token
|
(created.token
|
||||||
? `${window.location.origin}/accept-invite?token=${encodeURIComponent(created.token)}`
|
? `${window.location.origin}/accept-invite#token=${encodeURIComponent(created.token)}`
|
||||||
: "");
|
: "");
|
||||||
trackEvent("invite_sent", {
|
trackEvent("invite_sent", {
|
||||||
role: roleForEvent,
|
role: roleForEvent,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { Button } from "$lib/components/ui";
|
import { Button } from "$lib/components/ui";
|
||||||
import SiteHeader from "$lib/components/site/SiteHeader.svelte";
|
import SiteHeader from "$lib/components/site/SiteHeader.svelte";
|
||||||
import Footer from "$lib/components/site/Footer.svelte";
|
import Footer from "$lib/components/site/Footer.svelte";
|
||||||
|
import SeoHead from "$lib/components/site/SeoHead.svelte";
|
||||||
|
|
||||||
const API_BASE = (PUBLIC_API_URL ?? "").replace(/\/$/, "") || "";
|
const API_BASE = (PUBLIC_API_URL ?? "").replace(/\/$/, "") || "";
|
||||||
|
|
||||||
@@ -16,11 +17,12 @@
|
|||||||
let message = $state("");
|
let message = $state("");
|
||||||
let error = $state("");
|
let error = $state("");
|
||||||
let already = $state(false);
|
let already = $state(false);
|
||||||
|
let missingToken = $state(false);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
token = page.url.searchParams.get("token") ?? "";
|
token = page.url.searchParams.get("token") ?? "";
|
||||||
if (!token) {
|
if (!token) {
|
||||||
error = i18n.t("flash.unsubscribe.missingToken");
|
missingToken = true;
|
||||||
loading = false;
|
loading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -64,6 +66,12 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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">
|
<div class="flex min-h-screen flex-col bg-background">
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
<main
|
<main
|
||||||
@@ -73,6 +81,8 @@
|
|||||||
<h1 class="text-2xl font-semibold text-text">{i18n.t("unsubscribe.title")}</h1>
|
<h1 class="text-2xl font-semibold text-text">{i18n.t("unsubscribe.title")}</h1>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p class="text-sm text-text-muted">{i18n.t("common.loading")}</p>
|
<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}
|
{:else if error}
|
||||||
<p class="text-sm text-danger">{error}</p>
|
<p class="text-sm text-danger">{error}</p>
|
||||||
{:else}
|
{: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
Reference in New Issue
Block a user