This commit is contained in:
2026-08-16 11:37:42 +02:00
parent dc9ea628c1
commit 52f27e30fc
26 changed files with 1137 additions and 56 deletions
@@ -18,6 +18,7 @@ func ClientError(err error) (msg string, ok bool) {
errors.Is(err, ErrPlanNotFound), errors.Is(err, ErrPlanNotFound),
errors.Is(err, ErrAmountRequired), errors.Is(err, ErrAmountRequired),
errors.Is(err, ErrStripeNotConfigured), errors.Is(err, ErrStripeNotConfigured),
errors.Is(err, ErrStripeSelfServeUnavailable),
errors.Is(err, ErrStripePlanUnsupported), errors.Is(err, ErrStripePlanUnsupported),
errors.Is(err, ErrStripePriceMissing), errors.Is(err, ErrStripePriceMissing),
errors.Is(err, ErrStripeNoCustomer), errors.Is(err, ErrStripeNoCustomer),
+24 -4
View File
@@ -99,12 +99,32 @@ func (s *StripeService) cfg(ctx context.Context) StripeConfig {
} }
var ( var (
ErrStripeNotConfigured = errors.New("stripe not configured") ErrStripeNotConfigured = errors.New("stripe not configured")
ErrStripePlanUnsupported = errors.New("plan is not available for self-serve checkout") ErrStripeSelfServeUnavailable = errors.New("self-serve checkout is unavailable until Stripe is configured")
ErrStripePriceMissing = errors.New("stripe price id not configured for plan/term") ErrStripePlanUnsupported = errors.New("plan is not available for self-serve checkout")
ErrStripeBadSignature = errors.New("invalid stripe signature") ErrStripePriceMissing = errors.New("stripe price id not configured for plan/term")
ErrStripeBadSignature = errors.New("invalid stripe signature")
) )
// EnsureSelfServeCheckout allows live Stripe for any caller, and mock checkout
// only for platform admins (so new tenant users cannot buy while STRIPE_MOCK is on).
func (s *StripeService) EnsureSelfServeCheckout(ctx context.Context, platformAdmin bool) error {
_, cfg, err := s.bindCfg(ctx)
if err != nil {
return err
}
if !cfg.MockMode() {
return nil
}
if cfg.AllowMockPurchase() && platformAdmin {
return nil
}
if cfg.AllowMockPurchase() {
return ErrStripeSelfServeUnavailable
}
return ErrStripeNotConfigured
}
// CheckoutRequest is the body for POST /api/billing/checkout. // CheckoutRequest is the body for POST /api/billing/checkout.
// Set Pack for a one-time AI credit top-up, or Plan (+ Term) for a subscription. // Set Pack for a one-time AI credit top-up, or Plan (+ Term) for a subscription.
type CheckoutRequest struct { type CheckoutRequest struct {
+18
View File
@@ -93,6 +93,24 @@ func TestStripeConfigMockMode(t *testing.T) {
} }
} }
func TestEnsureSelfServeCheckout(t *testing.T) {
live := &StripeService{Cfg: StripeConfig{SecretKey: "sk_test_x"}}
if err := live.EnsureSelfServeCheckout(context.TODO(), false); err != nil {
t.Fatalf("live stripe should allow any admin: %v", err)
}
mock := &StripeService{Cfg: StripeConfig{ForceMock: true}}
if err := mock.EnsureSelfServeCheckout(context.TODO(), true); err != nil {
t.Fatalf("mock + platform admin should allow: %v", err)
}
if err := mock.EnsureSelfServeCheckout(context.TODO(), false); !errors.Is(err, ErrStripeSelfServeUnavailable) {
t.Fatalf("mock + non-platform admin: want ErrStripeSelfServeUnavailable, got %v", err)
}
empty := &StripeService{Cfg: StripeConfig{}}
if err := empty.EnsureSelfServeCheckout(context.TODO(), true); !errors.Is(err, ErrStripeNotConfigured) {
t.Fatalf("empty secret: want ErrStripeNotConfigured, got %v", err)
}
}
func TestCreateCheckoutSessionFailsClosedWithoutSecret(t *testing.T) { func TestCreateCheckoutSessionFailsClosedWithoutSecret(t *testing.T) {
s := &StripeService{Cfg: StripeConfig{WebOrigin: "http://localhost:5174"}} s := &StripeService{Cfg: StripeConfig{WebOrigin: "http://localhost:5174"}}
_, err := s.CreateCheckoutSession(context.TODO(), uuid.Nil, "a@b.c", "Acme", CheckoutRequest{Plan: "starter", Term: "monthly"}) _, err := s.CreateCheckoutSession(context.TODO(), uuid.Nil, "a@b.c", "Acme", CheckoutRequest{Plan: "starter", Term: "monthly"})
+444
View File
@@ -0,0 +1,444 @@
package catalog
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// CloneResult summarizes a company catalog clone (source unchanged).
type CloneResult struct {
SourceCompanyID uuid.UUID `json:"source_company_id"`
DestCompanyID uuid.UUID `json:"dest_company_id"`
Counts map[string]int64 `json:"counts"`
}
// ClearCompanyCatalog deletes operational catalog rows for a company.
// Does not touch billing, memberships, api_keys, store connectors, or users.
func ClearCompanyCatalog(ctx context.Context, tx pgx.Tx, companyID uuid.UUID) error {
stmts := []string{
`DELETE FROM processed_products WHERE company_id = $1`,
`DELETE FROM raw_products WHERE company_id = $1`,
`DELETE FROM export_feeds WHERE company_id = $1`,
`DELETE FROM feed_mappings WHERE company_id = $1`,
`DELETE FROM feed_sync_jobs WHERE company_id = $1`,
`DELETE FROM schema_extraction_tasks WHERE company_id = $1`,
`DELETE FROM feed_tag_mappings WHERE feed_id IN (SELECT id FROM input_feeds WHERE company_id = $1)
OR tag_id IN (SELECT id FROM feed_tags WHERE company_id = $1)`,
`DELETE FROM feed_tags WHERE company_id = $1`,
`DELETE FROM input_feeds WHERE company_id = $1`,
`DELETE FROM category_attributes WHERE company_id = $1`,
`DELETE FROM categories WHERE company_id = $1`,
`DELETE FROM attributes WHERE company_id = $1`,
`DELETE FROM custom_variables WHERE company_id = $1`,
`DELETE FROM standard_fields WHERE company_id = $1`,
`DELETE FROM field_groups WHERE company_id = $1`,
`DELETE FROM structured_description_fields WHERE company_id = $1`,
`DELETE FROM files WHERE company_id = $1`,
`DELETE FROM processing_job_products WHERE job_id IN (SELECT id FROM processing_jobs WHERE company_id = $1)`,
`DELETE FROM processing_jobs WHERE company_id = $1`,
`DELETE FROM tasks WHERE company_id = $1`,
`DELETE FROM product_reviews WHERE company_id = $1`,
`DELETE FROM woo_order_items WHERE company_id = $1`,
`DELETE FROM woo_orders WHERE company_id = $1`,
`DELETE FROM ai_prompt_templates WHERE company_id = $1`,
`DELETE FROM company_brand WHERE company_id = $1`,
}
for _, q := range stmts {
if _, err := tx.Exec(ctx, q, companyID); err != nil {
return fmt.Errorf("clear catalog: %s: %w", q, err)
}
}
return nil
}
// CloneCompanyCatalog copies operational catalog data from src into dest.
// Source rows are never updated. Dest catalog is cleared first.
// Skips plan/billing, memberships, api_keys, Shopify/Woo connectors, and AI BYOK secrets.
func (s *Service) CloneCompanyCatalog(ctx context.Context, src, dest uuid.UUID) (*CloneResult, error) {
if s == nil || s.Pool == nil {
return nil, fmt.Errorf("catalog service not configured")
}
if err := validateCloneCompanies(src, dest); err != nil {
return nil, err
}
return cloneCompanyCatalog(ctx, s.Pool, src, dest)
}
func validateCloneCompanies(src, dest uuid.UUID) error {
if src == uuid.Nil || dest == uuid.Nil {
return ClientMsg("source_company_id and dest_company_id are required")
}
if src == dest {
return ClientMsg("cannot clone a company catalog onto itself")
}
return nil
}
func cloneCompanyCatalog(ctx context.Context, pool *pgxpool.Pool, src, dest uuid.UUID) (*CloneResult, error) {
tx, err := pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
// Large A1-sized catalogs need headroom inside one transaction.
if _, err := tx.Exec(ctx, `SET LOCAL statement_timeout = '15min'`); err != nil {
return nil, fmt.Errorf("set statement_timeout: %w", err)
}
var srcOK, destOK bool
if err := tx.QueryRow(ctx, `
SELECT
EXISTS(SELECT 1 FROM companies WHERE id = $1),
EXISTS(SELECT 1 FROM companies WHERE id = $2)`, src, dest).Scan(&srcOK, &destOK); err != nil {
return nil, err
}
if !srcOK {
return nil, ClientMsg("source company not found")
}
if !destOK {
return nil, ClientMsg("destination company not found")
}
if err := ClearCompanyCatalog(ctx, tx, dest); err != nil {
return nil, err
}
if _, err := tx.Exec(ctx, `
CREATE TEMP TABLE clone_id_map (
kind TEXT NOT NULL,
old_id UUID NOT NULL,
new_id UUID NOT NULL,
PRIMARY KEY (kind, old_id)
) ON COMMIT DROP`); err != nil {
return nil, fmt.Errorf("create clone_id_map: %w", err)
}
counts := map[string]int64{}
add := func(label string, n int64) { counts[label] = n }
// --- taxonomy / attributes ---
ct, err := tx.Exec(ctx, `
INSERT INTO categories (
id, company_id, name, unique_id, parent_unique_id, path, level, position,
is_active, description, prompt, metadata, config, title_template, description_template,
created_at, updated_at
)
SELECT gen_random_uuid(), $2, name, unique_id, parent_unique_id, path, level, position,
is_active, description, prompt, metadata, config, title_template, description_template,
created_at, now()
FROM categories WHERE company_id = $1`, src, dest)
if err != nil {
return nil, fmt.Errorf("categories: %w", err)
}
add("categories", ct.RowsAffected())
if _, err := tx.Exec(ctx, `
INSERT INTO clone_id_map (kind, old_id, new_id)
SELECT 'attribute', id, gen_random_uuid() FROM attributes WHERE company_id = $1`, src); err != nil {
return nil, fmt.Errorf("map attributes: %w", err)
}
ct, err = tx.Exec(ctx, `
INSERT INTO attributes (
id, company_id, attribute_key, name, value_type, unit, example, parent_key, created_at, updated_at
)
SELECT m.new_id, $2, a.attribute_key, a.name, a.value_type, a.unit, a.example, a.parent_key, a.created_at, now()
FROM attributes a
JOIN clone_id_map m ON m.kind = 'attribute' AND m.old_id = a.id
WHERE a.company_id = $1`, src, dest)
if err != nil {
return nil, fmt.Errorf("attributes: %w", err)
}
add("attributes", ct.RowsAffected())
ct, err = tx.Exec(ctx, `
INSERT INTO category_attributes (
id, company_id, category_unique_id, attribute_id, required, created_at, updated_at
)
SELECT gen_random_uuid(), $2, ca.category_unique_id, m.new_id, ca.required, ca.created_at, now()
FROM category_attributes ca
JOIN clone_id_map m ON m.kind = 'attribute' AND m.old_id = ca.attribute_id
WHERE ca.company_id = $1`, src, dest)
if err != nil {
return nil, fmt.Errorf("category_attributes: %w", err)
}
add("category_attributes", ct.RowsAffected())
ct, err = tx.Exec(ctx, `
INSERT INTO custom_variables (id, company_id, name, value, description, created_at, updated_at)
SELECT gen_random_uuid(), $2, name, value, description, created_at, now()
FROM custom_variables WHERE company_id = $1`, src, dest)
if err != nil {
return nil, fmt.Errorf("custom_variables: %w", err)
}
add("custom_variables", ct.RowsAffected())
if _, err := tx.Exec(ctx, `
INSERT INTO clone_id_map (kind, old_id, new_id)
SELECT 'field_group', id, gen_random_uuid() FROM field_groups WHERE company_id = $1`, src); err != nil {
return nil, fmt.Errorf("map field_groups: %w", err)
}
ct, err = tx.Exec(ctx, `
INSERT INTO field_groups (id, company_id, name, description, "order", is_system, created_at, updated_at)
SELECT m.new_id, $2, fg.name, fg.description, fg."order", fg.is_system, fg.created_at, now()
FROM field_groups fg
JOIN clone_id_map m ON m.kind = 'field_group' AND m.old_id = fg.id
WHERE fg.company_id = $1`, src, dest)
if err != nil {
return nil, fmt.Errorf("field_groups: %w", err)
}
add("field_groups", ct.RowsAffected())
ct, err = tx.Exec(ctx, `
INSERT INTO standard_fields (
id, company_id, name, key, type, group_id, is_required, description,
default_value, validation, is_system, created_at, updated_at
)
SELECT gen_random_uuid(), $2, sf.name, sf.key, sf.type, m.new_id, sf.is_required, sf.description,
sf.default_value, sf.validation, sf.is_system, sf.created_at, now()
FROM standard_fields sf
JOIN clone_id_map m ON m.kind = 'field_group' AND m.old_id = sf.group_id
WHERE sf.company_id = $1`, src, dest)
if err != nil {
return nil, fmt.Errorf("standard_fields: %w", err)
}
add("standard_fields", ct.RowsAffected())
ct, err = tx.Exec(ctx, `
INSERT INTO structured_description_fields (id, company_id, field_key, type, created_at, updated_at)
SELECT gen_random_uuid(), $2, field_key, type, created_at, now()
FROM structured_description_fields WHERE company_id = $1`, src, dest)
if err != nil {
return nil, fmt.Errorf("structured_description_fields: %w", err)
}
add("structured_description_fields", ct.RowsAffected())
// --- files / feeds ---
if _, err := tx.Exec(ctx, `
INSERT INTO clone_id_map (kind, old_id, new_id)
SELECT 'file', id, gen_random_uuid() FROM files WHERE company_id = $1`, src); err != nil {
return nil, fmt.Errorf("map files: %w", err)
}
ct, err = tx.Exec(ctx, `
INSERT INTO files (
id, company_id, user_id, name, path, content_type, size_bytes, status, metadata, created_at, updated_at
)
SELECT m.new_id, $2, NULL, f.name, f.path, f.content_type, f.size_bytes, f.status, f.metadata, f.created_at, now()
FROM files f
JOIN clone_id_map m ON m.kind = 'file' AND m.old_id = f.id
WHERE f.company_id = $1`, src, dest)
if err != nil {
return nil, fmt.Errorf("files: %w", err)
}
add("files", ct.RowsAffected())
if _, err := tx.Exec(ctx, `
INSERT INTO clone_id_map (kind, old_id, new_id)
SELECT 'feed', id, gen_random_uuid() FROM input_feeds WHERE company_id = $1`, src); err != nil {
return nil, fmt.Errorf("map input_feeds: %w", err)
}
ct, err = tx.Exec(ctx, `
INSERT INTO input_feeds (
id, company_id, name, url, feed_type, status, sync_interval_minutes,
last_synced_at, auth_config, options, created_at, updated_at
)
SELECT m.new_id, $2, f.name, f.url, f.feed_type, f.status, f.sync_interval_minutes,
f.last_synced_at, f.auth_config, f.options, f.created_at, now()
FROM input_feeds f
JOIN clone_id_map m ON m.kind = 'feed' AND m.old_id = f.id
WHERE f.company_id = $1`, src, dest)
if err != nil {
return nil, fmt.Errorf("input_feeds: %w", err)
}
add("input_feeds", ct.RowsAffected())
ct, err = tx.Exec(ctx, `
INSERT INTO feed_mappings (id, feed_id, company_id, version, mappings, is_active, created_at, updated_at)
SELECT gen_random_uuid(), m.new_id, $2, fm.version, fm.mappings, fm.is_active, fm.created_at, now()
FROM feed_mappings fm
JOIN clone_id_map m ON m.kind = 'feed' AND m.old_id = fm.feed_id
WHERE fm.company_id = $1`, src, dest)
if err != nil {
return nil, fmt.Errorf("feed_mappings: %w", err)
}
add("feed_mappings", ct.RowsAffected())
if _, err := tx.Exec(ctx, `
INSERT INTO clone_id_map (kind, old_id, new_id)
SELECT 'feed_tag', id, gen_random_uuid() FROM feed_tags WHERE company_id = $1`, src); err != nil {
return nil, fmt.Errorf("map feed_tags: %w", err)
}
ct, err = tx.Exec(ctx, `
INSERT INTO feed_tags (id, company_id, name, color, created_at)
SELECT m.new_id, $2, t.name, t.color, t.created_at
FROM feed_tags t
JOIN clone_id_map m ON m.kind = 'feed_tag' AND m.old_id = t.id
WHERE t.company_id = $1`, src, dest)
if err != nil {
return nil, fmt.Errorf("feed_tags: %w", err)
}
add("feed_tags", ct.RowsAffected())
ct, err = tx.Exec(ctx, `
INSERT INTO feed_tag_mappings (feed_id, tag_id)
SELECT fm.new_id, tm.new_id
FROM feed_tag_mappings x
JOIN clone_id_map fm ON fm.kind = 'feed' AND fm.old_id = x.feed_id
JOIN clone_id_map tm ON tm.kind = 'feed_tag' AND tm.old_id = x.tag_id`)
if err != nil {
return nil, fmt.Errorf("feed_tag_mappings: %w", err)
}
add("feed_tag_mappings", ct.RowsAffected())
ct, err = tx.Exec(ctx, `
INSERT INTO export_feeds (
id, company_id, name, source_feed_id, format, public_token, template, filters,
is_active, last_generated_at, created_at, updated_at
)
SELECT gen_random_uuid(), $2, e.name, sm.new_id, e.format,
encode(gen_random_bytes(16), 'hex'), e.template, e.filters,
e.is_active, e.last_generated_at, e.created_at, now()
FROM export_feeds e
LEFT JOIN clone_id_map sm ON sm.kind = 'feed' AND sm.old_id = e.source_feed_id
WHERE e.company_id = $1`, src, dest)
if err != nil {
return nil, fmt.Errorf("export_feeds: %w", err)
}
add("export_feeds", ct.RowsAffected())
// --- products ---
if _, err := tx.Exec(ctx, `
INSERT INTO clone_id_map (kind, old_id, new_id)
SELECT 'raw_product', id, gen_random_uuid() FROM raw_products WHERE company_id = $1`, src); err != nil {
return nil, fmt.Errorf("map raw_products: %w", err)
}
ct, err = tx.Exec(ctx, `
INSERT INTO raw_products (
id, company_id, gtin, feed_id, feed_ids, raw_data, mapped_data, sync_job_id,
is_processed, processing_status, file_id, created_at, updated_at
)
SELECT
rm.new_id,
$2,
rp.gtin,
fm.new_id,
CASE
WHEN rp.feed_ids IS NULL THEN NULL
WHEN jsonb_typeof(rp.feed_ids) <> 'array' THEN rp.feed_ids
ELSE COALESCE((
SELECT jsonb_agg(to_jsonb(COALESCE(xmm.new_id::text, elem)))
FROM jsonb_array_elements_text(rp.feed_ids) AS t(elem)
LEFT JOIN clone_id_map xmm
ON xmm.kind = 'feed' AND xmm.old_id::text = t.elem
), '[]'::jsonb)
END,
rp.raw_data,
rp.mapped_data,
NULL,
rp.is_processed,
rp.processing_status,
filem.new_id,
rp.created_at,
now()
FROM raw_products rp
JOIN clone_id_map rm ON rm.kind = 'raw_product' AND rm.old_id = rp.id
LEFT JOIN clone_id_map fm ON fm.kind = 'feed' AND fm.old_id = rp.feed_id
LEFT JOIN clone_id_map filem ON filem.kind = 'file' AND filem.old_id = rp.file_id
WHERE rp.company_id = $1`, src, dest)
if err != nil {
return nil, fmt.Errorf("raw_products: %w", err)
}
add("raw_products", ct.RowsAffected())
ct, err = tx.Exec(ctx, `
INSERT INTO processed_products (
id, company_id, user_id, product_id, name, category, description, processed_description,
attributes, processed_attributes, status, gpt_response, total_tokens, feed_id, raw_product_id,
processed_name, meta_title, meta_description, last_transition_at, structured_description,
field_sources, created_at, updated_at, ai_provider_mode, localized_content
)
SELECT
gen_random_uuid(),
$2,
NULL,
pp.product_id,
pp.name,
pp.category,
pp.description,
pp.processed_description,
pp.attributes,
pp.processed_attributes,
pp.status,
pp.gpt_response,
pp.total_tokens,
fm.new_id,
rm.new_id,
pp.processed_name,
pp.meta_title,
pp.meta_description,
pp.last_transition_at,
pp.structured_description,
pp.field_sources,
pp.created_at,
now(),
COALESCE(pp.ai_provider_mode, 'internal'),
COALESCE(pp.localized_content, '{}'::jsonb)
FROM processed_products pp
LEFT JOIN clone_id_map fm ON fm.kind = 'feed' AND fm.old_id = pp.feed_id
LEFT JOIN clone_id_map rm ON rm.kind = 'raw_product' AND rm.old_id = pp.raw_product_id
WHERE pp.company_id = $1`, src, dest)
if err != nil {
return nil, fmt.Errorf("processed_products: %w", err)
}
add("processed_products", ct.RowsAffected())
ct, err = tx.Exec(ctx, `
INSERT INTO ai_prompt_templates (
id, company_id, prompt_key, system_template, user_template, is_enabled,
created_at, updated_at, language
)
SELECT gen_random_uuid(), $2, prompt_key, system_template, user_template, is_enabled,
created_at, now(), language
FROM ai_prompt_templates WHERE company_id = $1`, src, dest)
if err != nil {
return nil, fmt.Errorf("ai_prompt_templates: %w", err)
}
add("ai_prompt_templates", ct.RowsAffected())
ct, err = tx.Exec(ctx, `
INSERT INTO company_brand (
company_id, voice_tone, dos, donts, primary_color, secondary_color, logo_url, preferred_terms, updated_at
)
SELECT $2, voice_tone, dos, donts, primary_color, secondary_color, logo_url, preferred_terms, now()
FROM company_brand WHERE company_id = $1`, src, dest)
if err != nil {
return nil, fmt.Errorf("company_brand: %w", err)
}
add("company_brand", ct.RowsAffected())
// Copy content language settings onto dest (not name/plan/billing).
ct, err = tx.Exec(ctx, `
UPDATE companies AS d
SET language = s.language,
content_languages = s.content_languages,
updated_at = now()
FROM companies s
WHERE d.id = $2 AND s.id = $1`, src, dest)
if err != nil {
return nil, fmt.Errorf("companies language: %w", err)
}
add("company_language", ct.RowsAffected())
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &CloneResult{
SourceCompanyID: src,
DestCompanyID: dest,
Counts: counts,
}, nil
}
+26
View File
@@ -0,0 +1,26 @@
package catalog
import (
"testing"
"github.com/google/uuid"
)
func TestValidateCloneCompanies(t *testing.T) {
t.Parallel()
a := uuid.MustParse("11111111-1111-4111-8111-111111111111")
b := uuid.MustParse("22222222-2222-4222-8222-222222222222")
if err := validateCloneCompanies(uuid.Nil, b); err == nil {
t.Fatal("expected error for nil source")
}
if err := validateCloneCompanies(a, uuid.Nil); err == nil {
t.Fatal("expected error for nil dest")
}
if err := validateCloneCompanies(a, a); err == nil {
t.Fatal("expected error for src==dest")
}
if err := validateCloneCompanies(a, b); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
@@ -0,0 +1,108 @@
package httpapi
import (
"errors"
"net/http"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
// handleAdminCloneCompanyCatalog copies operational catalog data from the URL
// company into the admin's sandbox company (home / explicit dest). Source is
// never mutated. Body: confirm=true required; optional dest_company_id.
func (s *Server) handleAdminCloneCompanyCatalog(w http.ResponseWriter, r *http.Request) {
if s.Catalog == nil {
Error(w, http.StatusServiceUnavailable, "catalog service unavailable")
return
}
srcID, err := uuid.Parse(strings.TrimSpace(chi.URLParam(r, "id")))
if err != nil {
Error(w, http.StatusBadRequest, "invalid company id")
return
}
var body struct {
Confirm bool `json:"confirm"`
DestCompanyID string `json:"dest_company_id"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
if !body.Confirm {
Error(w, http.StatusBadRequest, "confirm must be true (destination catalog will be replaced)")
return
}
destID, err := s.resolveCloneDestCompany(r, body.DestCompanyID)
if err != nil {
Error(w, http.StatusBadRequest, err.Error())
return
}
if platformsettings.IsSystemCompany(srcID) || platformsettings.IsSystemCompany(destID) {
Error(w, http.StatusBadRequest, "cannot clone to or from the platform settings company")
return
}
var destLegacy, destName string
if err := s.Pool.QueryRow(r.Context(), `
SELECT COALESCE(legacy_company_id, ''), name FROM companies WHERE id = $1`, destID).
Scan(&destLegacy, &destName); err != nil {
Error(w, http.StatusBadRequest, "destination company not found")
return
}
if billing.IsA1CohortCompany(destLegacy, destName) {
Error(w, http.StatusBadRequest, "refusing to overwrite A1 cohort company; switch to your sandbox company")
return
}
res, err := s.Catalog.CloneCompanyCatalog(r.Context(), srcID, destID)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not clone catalog", err, catalog.ClientError)
return
}
JSON(w, http.StatusOK, map[string]any{
"status": "ok",
"source_company_id": res.SourceCompanyID,
"dest_company_id": res.DestCompanyID,
"counts": res.Counts,
})
}
// resolveCloneDestCompany prefers an explicit dest, then staff home (when acting
// in another tenant), else the session active company.
func (s *Server) resolveCloneDestCompany(r *http.Request, destRaw string) (uuid.UUID, error) {
if raw := strings.TrimSpace(destRaw); raw != "" {
id, err := uuid.Parse(raw)
if err != nil {
return uuid.Nil, errors.New("invalid dest_company_id")
}
return id, nil
}
if home := strings.TrimSpace(s.Sessions.GetString(r.Context(), auth.SessionStaffHomeCompanyKey)); home != "" {
id, err := uuid.Parse(home)
if err != nil {
return uuid.Nil, errors.New("invalid staff home company")
}
return id, nil
}
if cid, ok := CompanyIDFromContext(r.Context()); ok && cid != uuid.Nil {
return cid, nil
}
if cur := strings.TrimSpace(s.Sessions.GetString(r.Context(), auth.SessionCompanyIDKey)); cur != "" {
id, err := uuid.Parse(cur)
if err != nil {
return uuid.Nil, errors.New("invalid session company")
}
return id, nil
}
return uuid.Nil, errors.New("no destination company; select your sandbox company first")
}
@@ -0,0 +1,78 @@
package httpapi
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/alexedwards/scs/v2"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
"github.com/google/uuid"
)
func TestHandleAdminCloneCompanyCatalogNilCatalog(t *testing.T) {
t.Parallel()
s := &Server{}
req := httptest.NewRequest(http.MethodPost, "/api/admin/companies/"+uuid.NewString()+"/clone-catalog", strings.NewReader(`{"confirm":true}`))
rec := httptest.NewRecorder()
s.handleAdminCloneCompanyCatalog(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d want 503 body=%s", rec.Code, rec.Body.String())
}
}
// TestRouterAdminCloneCatalogMounted locks POST /api/admin/companies/{id}/clone-catalog
// after session + CSRF + platform-admin (503 with nil Catalog), not chi 404.
func TestRouterAdminCloneCatalogMounted(t *testing.T) {
t.Parallel()
sm := scs.New()
sm.Cookie.Name = "descrybe_session"
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
s := &Server{
Config: config.Config{
CSRFCookieName: "descrybe_csrf",
WebOrigin: "http://localhost:5173",
},
Sessions: sm,
Auth: &auth.Service{},
testPlatformAdmin: func(_ context.Context, got uuid.UUID) (bool, error) {
return got == uid, nil
},
}
var token string
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sm.Put(r.Context(), auth.SessionUserIDKey, uid.String())
w.WriteHeader(http.StatusNoContent)
}))
seedRec := httptest.NewRecorder()
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
for _, c := range seedRec.Result().Cookies() {
if c.Name == sm.Cookie.Name {
token = c.Value
}
}
if token == "" {
t.Fatal("expected session cookie from seed request")
}
h := s.Router()
path := "/api/admin/companies/" + uuid.NewString() + "/clone-catalog"
csrf := csrfCookieForSession(t, h, sm, token)
mounted := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"confirm":true}`))
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
req.AddCookie(csrf)
req.Header.Set("X-CSRF-Token", csrf.Value)
h.ServeHTTP(mounted, req)
if mounted.Code == http.StatusNotFound {
t.Fatalf("route not mounted: status=404 body=%s", mounted.Body.String())
}
if mounted.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d want 503 (nil catalog) body=%s", mounted.Code, mounted.Body.String())
}
}
+4 -3
View File
@@ -387,9 +387,10 @@ func (s *Server) Router() http.Handler {
r.Get("/users", s.handleAdminListUsers) r.Get("/users", s.handleAdminListUsers)
r.Patch("/users/{id}/staff-role", s.handleAdminSetStaffRole) r.Patch("/users/{id}/staff-role", s.handleAdminSetStaffRole)
r.Put("/support/agents/{id}", s.handleAdminSetSupportAgent) r.Put("/support/agents/{id}", s.handleAdminSetSupportAgent)
r.Get("/staff", s.handleAdminListStaff) r.Get("/staff", s.handleAdminListStaff)
r.Post("/users/{id}/dev-password", s.handleAdminDevSetPassword) r.Post("/users/{id}/dev-password", s.handleAdminDevSetPassword)
r.Get("/companies", s.handleAdminListCompanies) r.Get("/companies", s.handleAdminListCompanies)
r.Post("/companies/{id}/clone-catalog", s.handleAdminCloneCompanyCatalog)
r.Get("/readiness", s.handleAdminReadiness) r.Get("/readiness", s.handleAdminReadiness)
r.Get("/diagnostics", s.handleAdminDiagnostics) r.Get("/diagnostics", s.handleAdminDiagnostics)
r.Get("/analytics", s.handleAdminAnalytics) r.Get("/analytics", s.handleAdminAnalytics)
@@ -45,6 +45,15 @@ func (s *Server) handleStripeCheckout(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusForbidden, "admin required") Error(w, http.StatusForbidden, "admin required")
return return
} }
platformAdmin, err := s.checkPlatformAdmin(r.Context(), uid)
if err != nil {
LogAndError(w, http.StatusInternalServerError, "failed to verify platform admin", err)
return
}
if err := s.stripeSvc().EnsureSelfServeCheckout(r.Context(), platformAdmin); err != nil {
ClientOrLog(w, http.StatusServiceUnavailable, "checkout unavailable", err, billing.ClientError)
return
}
var body billing.CheckoutRequest var body billing.CheckoutRequest
if err := DecodeJSON(r, &body); err != nil { if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json") Error(w, http.StatusBadRequest, "invalid json")
@@ -100,6 +100,29 @@ func TestHandleStripeCheckoutMockRequiresAdmin(t *testing.T) {
} }
} }
func TestHandleStripeCheckoutMockBlocksNonPlatformAdmin(t *testing.T) {
t.Parallel()
s := &Server{
Config: config.Config{StripeMock: true},
Stripe: &billing.StripeService{Cfg: billing.StripeConfig{ForceMock: true}},
testPlatformAdmin: func(context.Context, uuid.UUID) (bool, error) {
return false, nil
},
}
cid := uuid.New()
uid := uuid.New()
ctx := context.WithValue(context.Background(), ctxUserID, uid)
ctx = context.WithValue(ctx, ctxCompanyID, cid)
ctx = context.WithValue(ctx, ctxRole, "admin")
req := httptest.NewRequest(http.MethodPost, "/api/billing/checkout", bytes.NewBufferString(`{"plan":"starter","term":"monthly"}`))
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
s.handleStripeCheckout(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d body=%s want 503", rec.Code, rec.Body.String())
}
}
func signHandlerStripePayload(t *testing.T, secret string, payload []byte) string { func signHandlerStripePayload(t *testing.T, secret string, payload []byte) string {
t.Helper() t.Helper()
ts := time.Now().Unix() ts := time.Now().Unix()
+24
View File
@@ -16,6 +16,8 @@ import {
export const ADMIN_USERS_PATH = "/api/admin/users"; export const ADMIN_USERS_PATH = "/api/admin/users";
export const ADMIN_COMPANIES_PATH = "/api/admin/companies"; export const ADMIN_COMPANIES_PATH = "/api/admin/companies";
export const ADMIN_CLONE_CATALOG_PATH = (companyId: string) =>
`${ADMIN_COMPANIES_PATH}/${encodeURIComponent(companyId)}/clone-catalog`;
export const ADMIN_STAFF_ROLE_PATH = (userId: string) => export const ADMIN_STAFF_ROLE_PATH = (userId: string) =>
`/api/admin/users/${encodeURIComponent(userId)}/staff-role`; `/api/admin/users/${encodeURIComponent(userId)}/staff-role`;
@@ -201,6 +203,28 @@ export async function setAdminStaffRole(
return res.user; return res.user;
} }
export type CloneCatalogResult = {
status: string;
source_company_id: string;
dest_company_id: string;
counts: Record<string, number>;
};
/** Copy source company catalog into the admin sandbox (home / dest). Source is unchanged. */
export async function cloneAdminCompanyCatalog(
sourceCompanyId: string,
opts?: { destCompanyId?: string }
): Promise<CloneCatalogResult> {
const body: { confirm: true; dest_company_id?: string } = { confirm: true };
if (opts?.destCompanyId?.trim()) {
body.dest_company_id = opts.destCompanyId.trim();
}
return api<CloneCatalogResult>(ADMIN_CLONE_CATALOG_PATH(sourceCompanyId), {
method: "POST",
body
});
}
export function isStaffRoleApiUnavailable(err: unknown): boolean { export function isStaffRoleApiUnavailable(err: unknown): boolean {
return err instanceof ApiError && (err.status === 404 || err.status === 501); return err instanceof ApiError && (err.status === 404 || err.status === 501);
} }
@@ -15,10 +15,12 @@
import { activateFocusTrap, type FocusTrapHandle } from "$lib/a11y/focus-trap"; import { activateFocusTrap, type FocusTrapHandle } from "$lib/a11y/focus-trap";
import type { CategoryOption, ProductRow } from "./types"; import type { CategoryOption, ProductRow } from "./types";
import { import {
categoryFieldDisplayValue,
enrichmentCardClass, enrichmentCardClass,
enrichmentChipClass, enrichmentChipClass,
enrichmentChipTitle, enrichmentChipTitle,
enrichmentStateLabel, enrichmentStateLabel,
findCategoryOption,
formatRelativeUpdated, formatRelativeUpdated,
isEnrichmentReviewStatus, isEnrichmentReviewStatus,
productAttrEntries, productAttrEntries,
@@ -174,6 +176,7 @@
void p.category; void p.category;
void p.category_unique_id; void p.category_unique_id;
void p.category_name; void p.category_name;
void categories;
void p.product_id; void p.product_id;
void p.sku; void p.sku;
void p.gtin; void p.gtin;
@@ -187,8 +190,11 @@
name = resolveOriginalName(p); name = resolveOriginalName(p);
description = resolveOriginalDescription(p); description = resolveOriginalDescription(p);
const rawCategory = String(p.category_unique_id ?? p.category ?? "").trim(); const rawCategory = String(p.category_unique_id ?? p.category ?? "").trim();
const matched = findCategoryOption(categories, rawCategory) ?? findCategoryOption(categories, p.category);
category = category =
rawCategory === "" || rawCategory.toLowerCase() === "none" ? "none" : rawCategory; rawCategory === "" || rawCategory.toLowerCase() === "none"
? "none"
: matched?.uniqueId || rawCategory;
status = String(p.status ?? p.processing_status ?? ""); status = String(p.status ?? p.processing_status ?? "");
productId = String(p.product_id ?? p.sku ?? p.gtin ?? ""); productId = String(p.product_id ?? p.sku ?? p.gtin ?? "");
const langs = const langs =
@@ -425,13 +431,18 @@
return attrs.filter((row) => editable.get(row.key.toLowerCase()) !== row.value); return attrs.filter((row) => editable.get(row.key.toLowerCase()) !== row.value);
}); });
const enrichment = $derived.by(() => (product ? productEnrichmentStates(product) : null)); const enrichment = $derived.by(() => (product ? productEnrichmentStates(product) : null));
const feedFields = $derived.by(() => productFeedFieldSummary(product)); const feedFields = $derived.by(() => productFeedFieldSummary(product, categories));
const feedMapped = $derived.by(() => productFeedMappedEntries(product)); const feedMapped = $derived.by(() => productFeedMappedEntries(product, categories));
const feedSync = $derived.by(() => (product ? productFeedSyncLabel(product) : "")); const feedSync = $derived.by(() => (product ? productFeedSyncLabel(product) : ""));
const feedFilledCount = $derived(feedMapped.filter((f) => f.ok).length); const feedFilledCount = $derived(feedMapped.filter((f) => f.ok).length);
const coverageCount = $derived( const coverageCount = $derived(
enrichment ? Object.values(enrichment).filter((s) => s !== "missing").length : 0 enrichment ? Object.values(enrichment).filter((s) => s !== "missing").length : 0
); );
function attrDisplayValue(key: string, value: string): string {
if (key.trim().toLowerCase() !== "category") return value;
return categoryFieldDisplayValue(categories, product, value);
}
</script> </script>
{#if open} {#if open}
@@ -741,16 +752,21 @@
bind:value={category} bind:value={category}
onchange={markDirty} onchange={markDirty}
> >
<option value="none">{i18n.t("products.edit.noCategory")}</option> <option value="none">{i18n.t("products.edit.noCategory")}</option>
{#each categories as cat} {#each categories as cat}
<option value={cat.uniqueId}>{cat.name}</option> <option value={cat.uniqueId}>{cat.name}</option>
{/each} {/each}
</select> {#if category !== "none" && !categories.some((c) => c.uniqueId === category)}
</div> <option value={category}
</TabsContent> >{categoryFieldDisplayValue(categories, product, category)}</option
{/if} >
{/if}
</select>
</div>
</TabsContent>
{/if}
<TabsContent value="details" class="mt-4 space-y-5"> <TabsContent value="details" class="mt-4 space-y-5">
{#if enrichment} {#if enrichment}
<section class="space-y-2"> <section class="space-y-2">
<div class="flex flex-wrap items-baseline justify-between gap-2"> <div class="flex flex-wrap items-baseline justify-between gap-2">
@@ -862,15 +878,20 @@
bind:value={category} bind:value={category}
onchange={markDirty} onchange={markDirty}
> >
<option value="none">{i18n.t("products.edit.noCategory")}</option> <option value="none">{i18n.t("products.edit.noCategory")}</option>
{#each categories as cat} {#each categories as cat}
<option value={cat.uniqueId}>{cat.name}</option> <option value={cat.uniqueId}>{cat.name}</option>
{/each} {/each}
</select> {#if category !== "none" && !categories.some((c) => c.uniqueId === category)}
</div> <option value={category}
</div> >{categoryFieldDisplayValue(categories, product, category)}</option
</section> >
</TabsContent> {/if}
</select>
</div>
</div>
</section>
</TabsContent>
<TabsContent value="feed" class="mt-4 space-y-4"> <TabsContent value="feed" class="mt-4 space-y-4">
<section class="rounded-md border border-border px-3 py-3"> <section class="rounded-md border border-border px-3 py-3">
@@ -1115,7 +1136,7 @@
{/if} {/if}
</div> </div>
<span class="whitespace-pre-wrap break-words text-sm text-muted-foreground" <span class="whitespace-pre-wrap break-words text-sm text-muted-foreground"
>{attr.value}</span >{attrDisplayValue(attr.key, attr.value)}</span
> >
</div> </div>
{/each} {/each}
@@ -1133,7 +1154,9 @@
class="flex items-start justify-between gap-3 rounded-md border border-border px-3 py-2" class="flex items-start justify-between gap-3 rounded-md border border-border px-3 py-2"
> >
<span class="text-sm font-medium">{attr.key}</span> <span class="text-sm font-medium">{attr.key}</span>
<span class="text-right text-sm text-muted-foreground">{attr.value}</span> <span class="text-right text-sm text-muted-foreground"
>{attrDisplayValue(attr.key, attr.value)}</span
>
</div> </div>
{/each} {/each}
</div> </div>
@@ -1150,7 +1173,9 @@
class="flex items-start justify-between gap-3 rounded-md border border-border px-3 py-2" class="flex items-start justify-between gap-3 rounded-md border border-border px-3 py-2"
> >
<span class="text-sm font-medium">{attr.key}</span> <span class="text-sm font-medium">{attr.key}</span>
<span class="text-right text-sm text-muted-foreground">{attr.value}</span> <span class="text-right text-sm text-muted-foreground"
>{attrDisplayValue(attr.key, attr.value)}</span
>
</div> </div>
{/each} {/each}
</div> </div>
+50 -5
View File
@@ -126,7 +126,15 @@ export function categoryDisplayName(
): string { ): string {
if (p && typeof p === "object") { if (p && typeof p === "object") {
const fromApi = String(p.category_name ?? "").trim(); const fromApi = String(p.category_name ?? "").trim();
if (fromApi) return fromApi; if (fromApi) {
// API falls back to the raw id when the JOIN misses; prefer catalog name when available.
const resolved =
findCategoryOption(options, p.category_unique_id)?.name ??
findCategoryOption(options, p.category)?.name ??
findCategoryOption(options, fromApi)?.name;
if (resolved) return resolved;
return fromApi;
}
const ref = String(p.category_unique_id ?? p.category ?? "").trim(); const ref = String(p.category_unique_id ?? p.category ?? "").trim();
if (!ref || ref.toLowerCase() === "none") return i18n.t("products.table.uncategorized"); if (!ref || ref.toLowerCase() === "none") return i18n.t("products.table.uncategorized");
return ( return (
@@ -140,6 +148,34 @@ export function categoryDisplayName(
return findCategoryOption(options, ref)?.name ?? ref; return findCategoryOption(options, ref)?.name ?? ref;
} }
/**
* Resolve a feed/attr category value (often a numeric unique_id) to a display name.
* Prefer catalog options, then product.category_name when the raw value matches the product category.
*/
export function categoryFieldDisplayValue(
options: CategoryOption[],
product: Pick<ProductRow, "category" | "category_name" | "category_unique_id"> | null | undefined,
rawValue: string
): string {
const raw = String(rawValue ?? "").trim();
if (!raw || raw === "—" || raw.toLowerCase() === "none") return raw;
const fromOptions = findCategoryOption(options, raw)?.name;
if (fromOptions) return fromOptions;
if (product) {
const productRef = String(product.category_unique_id ?? product.category ?? "").trim();
const productName = String(product.category_name ?? "").trim();
if (productName && (raw === productRef || raw === String(product.category ?? "").trim())) {
const better =
findCategoryOption(options, product.category_unique_id)?.name ??
findCategoryOption(options, product.category)?.name ??
findCategoryOption(options, productName)?.name;
if (better) return better;
if (productName !== raw) return productName;
}
}
return raw;
}
export function productDisplayName(p: ProductRow, kind: "processed" | "raw"): string { export function productDisplayName(p: ProductRow, kind: "processed" | "raw"): string {
const fallback = i18n.t("products.unnamed"); const fallback = i18n.t("products.unnamed");
if (kind === "processed") { if (kind === "processed") {
@@ -493,8 +529,11 @@ export function resolveOriginalName(product: ProductRow | null | undefined): str
} }
/** Compact feed-field preview for the product panel (preferred mapped_data keys). */ /** Compact feed-field preview for the product panel (preferred mapped_data keys). */
export function productFeedFieldSummary(product: ProductRow | null | undefined): { key: string; value: string; ok: boolean }[] { export function productFeedFieldSummary(
return productFeedMappedEntries(product).filter((e) => e.preferred); product: ProductRow | null | undefined,
options: CategoryOption[] = []
): { key: string; value: string; ok: boolean }[] {
return productFeedMappedEntries(product, options).filter((e) => e.preferred);
} }
/** Format any mapped_data value for display (scalars + nested JSON). */ /** Format any mapped_data value for display (scalars + nested JSON). */
@@ -523,7 +562,10 @@ export type FeedMappedEntry = {
}; };
/** Full mapped_data inventory for the Feed tab (preferred keys first, then AZ). */ /** Full mapped_data inventory for the Feed tab (preferred keys first, then AZ). */
export function productFeedMappedEntries(product: ProductRow | null | undefined): FeedMappedEntry[] { export function productFeedMappedEntries(
product: ProductRow | null | undefined,
options: CategoryOption[] = []
): FeedMappedEntry[] {
const mapped = asRecord(product?.mapped_data); const mapped = asRecord(product?.mapped_data);
if (!mapped) return []; if (!mapped) return [];
const preferred = [ const preferred = [
@@ -548,7 +590,10 @@ export function productFeedMappedEntries(product: ProductRow | null | undefined)
return a.localeCompare(b); return a.localeCompare(b);
}); });
return keys.map((key) => { return keys.map((key) => {
const formatted = formatMappedFeedValue(mapped[key]); let formatted = formatMappedFeedValue(mapped[key]);
if (key.toLowerCase() === "category" && formatted.trim()) {
formatted = categoryFieldDisplayValue(options, product, formatted);
}
const multiline = formatted.includes("\n") || formatted.length > 120; const multiline = formatted.includes("\n") || formatted.length > 120;
return { return {
key, key,
+13
View File
@@ -802,6 +802,16 @@ export const de: MessageDict = {
"admin.users.colCredits": "Credits", "admin.users.colCredits": "Credits",
"admin.users.colLanguage": "Sprache", "admin.users.colLanguage": "Sprache",
"admin.users.assignPlan": "Plan zuweisen", "admin.users.assignPlan": "Plan zuweisen",
"admin.users.cloneCatalog": "In mein Unternehmen kopieren",
"admin.users.cloneCatalogAria": "Katalog von {name} in mein Sandbox-Unternehmen kopieren",
"admin.users.cloneCatalogTitle": "Unternehmenskatalog kopieren",
"admin.users.cloneCatalogDesc": "Sie bleiben auf Ihrem Admin-Konto. Quelldaten bleiben unverändert.",
"admin.users.cloneCatalogFrom": "Quelle: {name}",
"admin.users.cloneCatalogInto": "Ziel: {name}",
"admin.users.cloneCatalogWarning": "Ersetzt Kategorien, Feeds, Produkte, Attribute, Mappings und Branding im Ziel. Plan, Abrechnung, Mitglieder, API-Schlüssel und Store-Connectoren werden nicht kopiert.",
"admin.users.cloneCatalogCancel": "Abbrechen",
"admin.users.cloneCatalogConfirm": "Katalog kopieren",
"admin.users.cloneDestFallback": "Ihr Sandbox-Unternehmen",
"admin.users.noUsers": "Keine Benutzer entsprechen diesem Filter.", "admin.users.noUsers": "Keine Benutzer entsprechen diesem Filter.",
"admin.users.noCompanies": "Keine Unternehmen entsprechen diesem Filter.", "admin.users.noCompanies": "Keine Unternehmen entsprechen diesem Filter.",
"admin.users.assignRoleTitle": "Mitarbeiterrolle zuweisen", "admin.users.assignRoleTitle": "Mitarbeiterrolle zuweisen",
@@ -2286,6 +2296,7 @@ export const de: MessageDict = {
"flash.admin.staffRoleUpdated": "Mitarbeiterrolle für {email} aktualisiert.", "flash.admin.staffRoleUpdated": "Mitarbeiterrolle für {email} aktualisiert.",
"flash.admin.staffRoleUnavailable": "Mitarbeiterrollen-Updates sind auf diesem Server noch nicht verfügbar.", "flash.admin.staffRoleUnavailable": "Mitarbeiterrollen-Updates sind auf diesem Server noch nicht verfügbar.",
"flash.admin.planAssigned": "Plan {name} zugewiesen.", "flash.admin.planAssigned": "Plan {name} zugewiesen.",
"flash.admin.catalogCloned": "{source} nach {dest} kopiert: {products} Produkte, {categories} Kategorien.",
"flash.admin.planAssignedShort": "Plan zugewiesen.", "flash.admin.planAssignedShort": "Plan zugewiesen.",
"flash.admin.creditsUpdated": "Credits aktualisiert.", "flash.admin.creditsUpdated": "Credits aktualisiert.",
"flash.admin.cyclesProcessed": "Abrechnungszyklen verarbeitet: {count}", "flash.admin.cyclesProcessed": "Abrechnungszyklen verarbeitet: {count}",
@@ -3387,6 +3398,7 @@ export const de: MessageDict = {
"billing.needCapacity": "Mehr Kapazität nötig?", "billing.needCapacity": "Mehr Kapazität nötig?",
"billing.selfServeUpgrade": "Self-Service-Upgrade auf Starter, Growth oder Business", "billing.selfServeUpgrade": "Self-Service-Upgrade auf Starter, Growth oder Business",
"billing.viaStripe": "über Stripe", "billing.viaStripe": "über Stripe",
"billing.checkoutUnavailable": "Selbstbedienungskäufe sind nicht verfügbar, bis Stripe eingerichtet ist. Kontaktieren Sie den Vertrieb für einen Plan.",
"billing.upgradeToGrowth": "Auf Growth upgraden", "billing.upgradeToGrowth": "Auf Growth upgraden",
"billing.askAdminPlan": "Bitten Sie einen Firmen-Admin, den Plan zu ändern.", "billing.askAdminPlan": "Bitten Sie einen Firmen-Admin, den Plan zu ändern.",
"billing.usage": "Nutzung", "billing.usage": "Nutzung",
@@ -4821,6 +4833,7 @@ export const de: MessageDict = {
"plans.sub.creditsSuffix": "Upgrade Starter → Growth → Business über Checkout, oder sprechen Sie mit dem Vertrieb über Enterprise.", "plans.sub.creditsSuffix": "Upgrade Starter → Growth → Business über Checkout, oder sprechen Sie mit dem Vertrieb über Enterprise.",
"plans.sub.none": "Noch kein Plan zugewiesen (z. B. nach einer übersprungenen Migration). Wählen Sie unten einen Plan — die Kapazität ist nicht Unbegrenzt, bis Checkout oder ein Admin einen zuweist.", "plans.sub.none": "Noch kein Plan zugewiesen (z. B. nach einer übersprungenen Migration). Wählen Sie unten einen Plan — die Kapazität ist nicht Unbegrenzt, bis Checkout oder ein Admin einen zuweist.",
"plans.stripeHint": "Self-Service-Upgrades nutzen Stripe Checkout.", "plans.stripeHint": "Self-Service-Upgrades nutzen Stripe Checkout.",
"plans.checkoutUnavailable": "Self-Service-Checkout ist nicht verfügbar, bis Stripe eingerichtet ist. Kontaktieren Sie den Vertrieb für einen Plan.",
"plans.fallbackName": "Tarifplan", "plans.fallbackName": "Tarifplan",
"plans.fallbackDescription": "Kapazität für Ihren Katalog", "plans.fallbackDescription": "Kapazität für Ihren Katalog",
"plans.badge.current": "Aktuell", "plans.badge.current": "Aktuell",
+20 -3
View File
@@ -814,13 +814,27 @@ export const en: MessageDict = {
"admin.users.mustSetPassword": "Must set password", "admin.users.mustSetPassword": "Must set password",
"admin.users.inactive": "Inactive", "admin.users.inactive": "Inactive",
"admin.users.reissueInvite": "Re-issue invite", "admin.users.reissueInvite": "Re-issue invite",
"admin.users.setLocalPassword": "Set local password", "admin.users.setLocalPassword": "Set password",
"admin.users.setPasswordTitle": "Set password",
"admin.users.setPasswordDesc": "Force-set a login password for this user (works for legacy or fake emails that cannot receive invites).",
"admin.users.newPassword": "New password",
"admin.users.setPasswordSubmit": "Set password",
"admin.users.switchToUser": "Switch to user", "admin.users.switchToUser": "Switch to user",
"admin.users.colCompany": "Company", "admin.users.colCompany": "Company",
"admin.users.colPlan": "Plan", "admin.users.colPlan": "Plan",
"admin.users.colCredits": "Credits", "admin.users.colCredits": "Credits",
"admin.users.colLanguage": "Language", "admin.users.colLanguage": "Language",
"admin.users.assignPlan": "Assign plan", "admin.users.assignPlan": "Assign plan",
"admin.users.cloneCatalog": "Copy to my company",
"admin.users.cloneCatalogAria": "Copy {name} catalog into your sandbox company",
"admin.users.cloneCatalogTitle": "Copy company catalog",
"admin.users.cloneCatalogDesc": "Stay on your admin account. Source data is not changed.",
"admin.users.cloneCatalogFrom": "Source: {name}",
"admin.users.cloneCatalogInto": "Destination: {name}",
"admin.users.cloneCatalogWarning": "Replaces categories, feeds, products, attributes, mappings, and brand on the destination. Plan, billing, members, API keys, and store connectors are not copied.",
"admin.users.cloneCatalogCancel": "Cancel",
"admin.users.cloneCatalogConfirm": "Copy catalog",
"admin.users.cloneDestFallback": "your sandbox company",
"admin.users.noUsers": "No users match this filter.", "admin.users.noUsers": "No users match this filter.",
"admin.users.noCompanies": "No companies match this filter.", "admin.users.noCompanies": "No companies match this filter.",
"admin.users.assignRoleTitle": "Assign staff role", "admin.users.assignRoleTitle": "Assign staff role",
@@ -2307,14 +2321,15 @@ export const en: MessageDict = {
"flash.admin.staffRoleUpdated": "Staff role updated for {email}.", "flash.admin.staffRoleUpdated": "Staff role updated for {email}.",
"flash.admin.staffRoleUnavailable": "Staff role updates are not available on this server yet.", "flash.admin.staffRoleUnavailable": "Staff role updates are not available on this server yet.",
"flash.admin.planAssigned": "Plan assigned to {name}.", "flash.admin.planAssigned": "Plan assigned to {name}.",
"flash.admin.catalogCloned": "Copied {source} into {dest}: {products} products, {categories} categories.",
"flash.admin.planAssignedShort": "Plan assigned.", "flash.admin.planAssignedShort": "Plan assigned.",
"flash.admin.creditsUpdated": "Credits updated.", "flash.admin.creditsUpdated": "Credits updated.",
"flash.admin.cyclesProcessed": "Billing cycles processed: {count}", "flash.admin.cyclesProcessed": "Billing cycles processed: {count}",
"flash.admin.invitesSummary": "Set-password invites: {parts}. Email delivery is {smtp}.", "flash.admin.invitesSummary": "Set-password invites: {parts}. Email delivery is {smtp}.",
"flash.admin.smtpOn": "on", "flash.admin.smtpOn": "on",
"flash.admin.smtpOff": "off", "flash.admin.smtpOff": "off",
"flash.admin.localPasswordSet": "Local password set for {email}.", "flash.admin.localPasswordSet": "Password set for {email}.",
"flash.admin.localPasswordUnavailable": "Local password tools are not available in this environment.", "flash.admin.localPasswordUnavailable": "Set-password tools are not available in this environment.",
"flash.admin.switchedUser": "Switched user. Reloading…", "flash.admin.switchedUser": "Switched user. Reloading…",
"flash.admin.switchUnavailable": "User switch is not available in this environment.", "flash.admin.switchUnavailable": "User switch is not available in this environment.",
"flash.admin.diagnosticsRateLimit": "Diagnostics rate limit reached (20/min). Wait a minute and refresh.", "flash.admin.diagnosticsRateLimit": "Diagnostics rate limit reached (20/min). Wait a minute and refresh.",
@@ -3410,6 +3425,7 @@ export const en: MessageDict = {
"billing.needCapacity": "Need more capacity?", "billing.needCapacity": "Need more capacity?",
"billing.selfServeUpgrade": "Self-serve upgrade to Starter, Growth, or Business", "billing.selfServeUpgrade": "Self-serve upgrade to Starter, Growth, or Business",
"billing.viaStripe": "via Stripe", "billing.viaStripe": "via Stripe",
"billing.checkoutUnavailable": "Self-serve purchases are unavailable until Stripe is configured. Contact sales for a plan.",
"billing.upgradeToGrowth": "Upgrade to Growth", "billing.upgradeToGrowth": "Upgrade to Growth",
"billing.askAdminPlan": "Ask a company admin to change the plan.", "billing.askAdminPlan": "Ask a company admin to change the plan.",
"billing.usage": "Usage", "billing.usage": "Usage",
@@ -4844,6 +4860,7 @@ export const en: MessageDict = {
"plans.sub.creditsSuffix": "Upgrade Starter → Growth → Business with Checkout, or talk to sales for Enterprise.", "plans.sub.creditsSuffix": "Upgrade Starter → Growth → Business with Checkout, or talk to sales for Enterprise.",
"plans.sub.none": "No plan is assigned yet (for example after a skipped migration). Choose a plan below — capacity is not Unlimited until Checkout or an admin assigns one.", "plans.sub.none": "No plan is assigned yet (for example after a skipped migration). Choose a plan below — capacity is not Unlimited until Checkout or an admin assigns one.",
"plans.stripeHint": "Self-serve upgrades use Stripe Checkout.", "plans.stripeHint": "Self-serve upgrades use Stripe Checkout.",
"plans.checkoutUnavailable": "Self-serve checkout is unavailable until Stripe is configured. Contact sales to get a plan.",
"plans.fallbackName": "Plan", "plans.fallbackName": "Plan",
"plans.fallbackDescription": "Capacity for your catalog", "plans.fallbackDescription": "Capacity for your catalog",
"plans.badge.current": "Current", "plans.badge.current": "Current",
+13
View File
@@ -802,6 +802,16 @@ export const es: MessageDict = {
"admin.users.colCredits": "Créditos", "admin.users.colCredits": "Créditos",
"admin.users.colLanguage": "Idioma", "admin.users.colLanguage": "Idioma",
"admin.users.assignPlan": "Asignar plan", "admin.users.assignPlan": "Asignar plan",
"admin.users.cloneCatalog": "Copiar a mi empresa",
"admin.users.cloneCatalogAria": "Copiar el catálogo de {name} a tu empresa sandbox",
"admin.users.cloneCatalogTitle": "Copiar catálogo de empresa",
"admin.users.cloneCatalogDesc": "Sigues en tu cuenta de admin. Los datos de origen no cambian.",
"admin.users.cloneCatalogFrom": "Origen: {name}",
"admin.users.cloneCatalogInto": "Destino: {name}",
"admin.users.cloneCatalogWarning": "Sustituye categorías, feeds, productos, atributos, mappings y marca en el destino. Plan, facturación, miembros, claves API y conectores de tienda no se copian.",
"admin.users.cloneCatalogCancel": "Cancelar",
"admin.users.cloneCatalogConfirm": "Copiar catálogo",
"admin.users.cloneDestFallback": "tu empresa sandbox",
"admin.users.noUsers": "Ningún usuario coincide con este filtro.", "admin.users.noUsers": "Ningún usuario coincide con este filtro.",
"admin.users.noCompanies": "Ninguna empresa coincide con este filtro.", "admin.users.noCompanies": "Ninguna empresa coincide con este filtro.",
"admin.users.assignRoleTitle": "Asignar rol de personal", "admin.users.assignRoleTitle": "Asignar rol de personal",
@@ -2285,6 +2295,7 @@ export const es: MessageDict = {
"flash.processing.retried": "\"{name}\" en cola para reintento.", "flash.processing.retried": "\"{name}\" en cola para reintento.",
"flash.admin.staffRoleUpdated": "Rol de personal actualizado para {email}.", "flash.admin.staffRoleUpdated": "Rol de personal actualizado para {email}.",
"flash.admin.staffRoleUnavailable": "Las actualizaciones de rol de personal aún no están disponibles en este servidor.", "flash.admin.staffRoleUnavailable": "Las actualizaciones de rol de personal aún no están disponibles en este servidor.",
"flash.admin.catalogCloned": "Se copió {source} en {dest}: {products} productos, {categories} categorías.",
"flash.admin.planAssigned": "Plan asignado a {name}.", "flash.admin.planAssigned": "Plan asignado a {name}.",
"flash.admin.planAssignedShort": "Plan asignado.", "flash.admin.planAssignedShort": "Plan asignado.",
"flash.admin.creditsUpdated": "Créditos actualizados.", "flash.admin.creditsUpdated": "Créditos actualizados.",
@@ -3387,6 +3398,7 @@ export const es: MessageDict = {
"billing.needCapacity": "¿Necesitas más capacidad?", "billing.needCapacity": "¿Necesitas más capacidad?",
"billing.selfServeUpgrade": "Mejora autoservicio a Starter, Growth o Business", "billing.selfServeUpgrade": "Mejora autoservicio a Starter, Growth o Business",
"billing.viaStripe": "vía Stripe", "billing.viaStripe": "vía Stripe",
"billing.checkoutUnavailable": "Las compras de autoservicio no estan disponibles hasta que Stripe este configurado. Contacta con ventas para un plan.",
"billing.upgradeToGrowth": "Mejorar a Growth", "billing.upgradeToGrowth": "Mejorar a Growth",
"billing.askAdminPlan": "Pide a un administrador de la empresa que cambie el plan.", "billing.askAdminPlan": "Pide a un administrador de la empresa que cambie el plan.",
"billing.usage": "Uso", "billing.usage": "Uso",
@@ -4821,6 +4833,7 @@ export const es: MessageDict = {
"plans.sub.creditsSuffix": "Mejora Starter → Growth → Business con Checkout, o habla con ventas para Enterprise.", "plans.sub.creditsSuffix": "Mejora Starter → Growth → Business con Checkout, o habla con ventas para Enterprise.",
"plans.sub.none": "Aún no hay un plan asignado (por ejemplo tras una migración omitida). Elige un plan abajo — la capacidad no es Ilimitada hasta que Checkout o un administrador asigne uno.", "plans.sub.none": "Aún no hay un plan asignado (por ejemplo tras una migración omitida). Elige un plan abajo — la capacidad no es Ilimitada hasta que Checkout o un administrador asigne uno.",
"plans.stripeHint": "Los upgrades de autoservicio usan Stripe Checkout.", "plans.stripeHint": "Los upgrades de autoservicio usan Stripe Checkout.",
"plans.checkoutUnavailable": "El checkout de autoservicio no esta disponible hasta que Stripe este configurado. Contacta con ventas para un plan.",
"plans.fallbackName": "Plan tarifario", "plans.fallbackName": "Plan tarifario",
"plans.fallbackDescription": "Capacidad para tu catálogo", "plans.fallbackDescription": "Capacidad para tu catálogo",
"plans.badge.current": "Actual", "plans.badge.current": "Actual",
+14 -1
View File
@@ -801,7 +801,17 @@ export const fr: MessageDict = {
"admin.users.colPlan": "Offre", "admin.users.colPlan": "Offre",
"admin.users.colCredits": "Crédits", "admin.users.colCredits": "Crédits",
"admin.users.colLanguage": "Langue", "admin.users.colLanguage": "Langue",
"admin.users.assignPlan": "Assigner une offre", "admin.users.assignPlan": "Attribuer un forfait",
"admin.users.cloneCatalog": "Copier vers mon entreprise",
"admin.users.cloneCatalogAria": "Copier le catalogue de {name} vers votre entreprise sandbox",
"admin.users.cloneCatalogTitle": "Copier le catalogue d'entreprise",
"admin.users.cloneCatalogDesc": "Vous restez sur votre compte admin. Les données source ne sont pas modifiées.",
"admin.users.cloneCatalogFrom": "Source : {name}",
"admin.users.cloneCatalogInto": "Destination : {name}",
"admin.users.cloneCatalogWarning": "Remplace catégories, feeds, produits, attributs, mappings et marque sur la destination. Forfait, facturation, membres, clés API et connecteurs boutique ne sont pas copiés.",
"admin.users.cloneCatalogCancel": "Annuler",
"admin.users.cloneCatalogConfirm": "Copier le catalogue",
"admin.users.cloneDestFallback": "votre entreprise sandbox",
"admin.users.noUsers": "Aucun utilisateur ne correspond à ce filtre.", "admin.users.noUsers": "Aucun utilisateur ne correspond à ce filtre.",
"admin.users.noCompanies": "Aucune entreprise ne correspond à ce filtre.", "admin.users.noCompanies": "Aucune entreprise ne correspond à ce filtre.",
"admin.users.assignRoleTitle": "Assigner un rôle du personnel", "admin.users.assignRoleTitle": "Assigner un rôle du personnel",
@@ -2285,6 +2295,7 @@ export const fr: MessageDict = {
"flash.processing.retried": "« {name} » mis en file pour nouvel essai.", "flash.processing.retried": "« {name} » mis en file pour nouvel essai.",
"flash.admin.staffRoleUpdated": "Rôle du personnel mis à jour pour {email}.", "flash.admin.staffRoleUpdated": "Rôle du personnel mis à jour pour {email}.",
"flash.admin.staffRoleUnavailable": "Les mises à jour de rôle personnel ne sont pas encore disponibles sur ce serveur.", "flash.admin.staffRoleUnavailable": "Les mises à jour de rôle personnel ne sont pas encore disponibles sur ce serveur.",
"flash.admin.catalogCloned": "{source} copié vers {dest} : {products} produits, {categories} catégories.",
"flash.admin.planAssigned": "Offre assignée à {name}.", "flash.admin.planAssigned": "Offre assignée à {name}.",
"flash.admin.planAssignedShort": "Offre assignée.", "flash.admin.planAssignedShort": "Offre assignée.",
"flash.admin.creditsUpdated": "Crédits mis à jour.", "flash.admin.creditsUpdated": "Crédits mis à jour.",
@@ -3387,6 +3398,7 @@ export const fr: MessageDict = {
"billing.needCapacity": "Besoin de plus de capacité ?", "billing.needCapacity": "Besoin de plus de capacité ?",
"billing.selfServeUpgrade": "Mise à niveau en libre-service vers Starter, Growth ou Business", "billing.selfServeUpgrade": "Mise à niveau en libre-service vers Starter, Growth ou Business",
"billing.viaStripe": "via Stripe", "billing.viaStripe": "via Stripe",
"billing.checkoutUnavailable": "Les achats en libre-service sont indisponibles tant que Stripe n'est pas configuré. Contactez les ventes pour un forfait.",
"billing.upgradeToGrowth": "Passer à Growth", "billing.upgradeToGrowth": "Passer à Growth",
"billing.askAdminPlan": "Demandez à un administrateur de changer le plan.", "billing.askAdminPlan": "Demandez à un administrateur de changer le plan.",
"billing.usage": "Utilisation", "billing.usage": "Utilisation",
@@ -4821,6 +4833,7 @@ export const fr: MessageDict = {
"plans.sub.creditsSuffix": "Passez de Starter → Growth → Business via Checkout, ou contactez les ventes pour Enterprise.", "plans.sub.creditsSuffix": "Passez de Starter → Growth → Business via Checkout, ou contactez les ventes pour Enterprise.",
"plans.sub.none": "Aucun plan n'est encore attribué (par exemple après une migration ignorée). Choisissez un plan ci-dessous — la capacité n'est pas Illimitée tant que Checkout ou un administrateur n'en a pas attribué un.", "plans.sub.none": "Aucun plan n'est encore attribué (par exemple après une migration ignorée). Choisissez un plan ci-dessous — la capacité n'est pas Illimitée tant que Checkout ou un administrateur n'en a pas attribué un.",
"plans.stripeHint": "Les mises à niveau en libre-service utilisent Stripe Checkout.", "plans.stripeHint": "Les mises à niveau en libre-service utilisent Stripe Checkout.",
"plans.checkoutUnavailable": "Le paiement en libre-service est indisponible tant que Stripe n'est pas configuré. Contactez les ventes pour un forfait.",
"plans.fallbackName": "Offre", "plans.fallbackName": "Offre",
"plans.fallbackDescription": "Capacité pour votre catalogue", "plans.fallbackDescription": "Capacité pour votre catalogue",
"plans.badge.current": "Actuel", "plans.badge.current": "Actuel",
+13
View File
@@ -802,6 +802,16 @@ export const it: MessageDict = {
"admin.users.colCredits": "Crediti", "admin.users.colCredits": "Crediti",
"admin.users.colLanguage": "Lingua", "admin.users.colLanguage": "Lingua",
"admin.users.assignPlan": "Assegna piano", "admin.users.assignPlan": "Assegna piano",
"admin.users.cloneCatalog": "Copia nella mia azienda",
"admin.users.cloneCatalogAria": "Copia il catalogo di {name} nella tua azienda sandbox",
"admin.users.cloneCatalogTitle": "Copia catalogo azienda",
"admin.users.cloneCatalogDesc": "Resti sul tuo account admin. I dati di origine non vengono modificati.",
"admin.users.cloneCatalogFrom": "Origine: {name}",
"admin.users.cloneCatalogInto": "Destinazione: {name}",
"admin.users.cloneCatalogWarning": "Sostituisce categorie, feed, prodotti, attributi, mapping e brand sulla destinazione. Piano, fatturazione, membri, chiavi API e connettori store non vengono copiati.",
"admin.users.cloneCatalogCancel": "Annulla",
"admin.users.cloneCatalogConfirm": "Copia catalogo",
"admin.users.cloneDestFallback": "la tua azienda sandbox",
"admin.users.noUsers": "Nessun utente corrisponde a questo filtro.", "admin.users.noUsers": "Nessun utente corrisponde a questo filtro.",
"admin.users.noCompanies": "Nessuna azienda corrisponde a questo filtro.", "admin.users.noCompanies": "Nessuna azienda corrisponde a questo filtro.",
"admin.users.assignRoleTitle": "Assegna ruolo staff", "admin.users.assignRoleTitle": "Assegna ruolo staff",
@@ -2285,6 +2295,7 @@ export const it: MessageDict = {
"flash.processing.retried": "\"{name}\" messo in coda per riprovare.", "flash.processing.retried": "\"{name}\" messo in coda per riprovare.",
"flash.admin.staffRoleUpdated": "Ruolo staff aggiornato per {email}.", "flash.admin.staffRoleUpdated": "Ruolo staff aggiornato per {email}.",
"flash.admin.staffRoleUnavailable": "Gli aggiornamenti del ruolo staff non sono ancora disponibili su questo server.", "flash.admin.staffRoleUnavailable": "Gli aggiornamenti del ruolo staff non sono ancora disponibili su questo server.",
"flash.admin.catalogCloned": "Copiato {source} in {dest}: {products} prodotti, {categories} categorie.",
"flash.admin.planAssigned": "Piano assegnato a {name}.", "flash.admin.planAssigned": "Piano assegnato a {name}.",
"flash.admin.planAssignedShort": "Piano assegnato.", "flash.admin.planAssignedShort": "Piano assegnato.",
"flash.admin.creditsUpdated": "Crediti aggiornati.", "flash.admin.creditsUpdated": "Crediti aggiornati.",
@@ -3387,6 +3398,7 @@ export const it: MessageDict = {
"billing.needCapacity": "Serve più capacità?", "billing.needCapacity": "Serve più capacità?",
"billing.selfServeUpgrade": "Upgrade self-service a Starter, Growth o Business", "billing.selfServeUpgrade": "Upgrade self-service a Starter, Growth o Business",
"billing.viaStripe": "tramite Stripe", "billing.viaStripe": "tramite Stripe",
"billing.checkoutUnavailable": "Gli acquisti self-service non sono disponibili finche Stripe non e configurato. Contatta le vendite per un piano.",
"billing.upgradeToGrowth": "Passa a Growth", "billing.upgradeToGrowth": "Passa a Growth",
"billing.askAdminPlan": "Chiedi a un amministratore di cambiare il piano.", "billing.askAdminPlan": "Chiedi a un amministratore di cambiare il piano.",
"billing.usage": "Utilizzo", "billing.usage": "Utilizzo",
@@ -4821,6 +4833,7 @@ export const it: MessageDict = {
"plans.sub.creditsSuffix": "Passa da Starter → Growth → Business con Checkout, oppure parla con le vendite per Enterprise.", "plans.sub.creditsSuffix": "Passa da Starter → Growth → Business con Checkout, oppure parla con le vendite per Enterprise.",
"plans.sub.none": "Nessun piano è ancora assegnato (ad esempio dopo una migrazione saltata). Scegli un piano qui sotto — la capacità non è Illimitata finché Checkout o un amministratore non ne assegna uno.", "plans.sub.none": "Nessun piano è ancora assegnato (ad esempio dopo una migrazione saltata). Scegli un piano qui sotto — la capacità non è Illimitata finché Checkout o un amministratore non ne assegna uno.",
"plans.stripeHint": "Gli upgrade self-service usano Stripe Checkout.", "plans.stripeHint": "Gli upgrade self-service usano Stripe Checkout.",
"plans.checkoutUnavailable": "Il checkout self-service non e disponibile finche Stripe non e configurato. Contatta le vendite per un piano.",
"plans.fallbackName": "Piano", "plans.fallbackName": "Piano",
"plans.fallbackDescription": "Capacità per il tuo catalogo", "plans.fallbackDescription": "Capacità per il tuo catalogo",
"plans.badge.current": "Attuale", "plans.badge.current": "Attuale",
+13
View File
@@ -802,6 +802,16 @@ export const ja: MessageDict = {
"admin.users.colCredits": "クレジット", "admin.users.colCredits": "クレジット",
"admin.users.colLanguage": "言語", "admin.users.colLanguage": "言語",
"admin.users.assignPlan": "プランを割り当て", "admin.users.assignPlan": "プランを割り当て",
"admin.users.cloneCatalog": "自分の会社にコピー",
"admin.users.cloneCatalogAria": "{name} のカタログをサンドボックス会社へコピー",
"admin.users.cloneCatalogTitle": "会社カタログをコピー",
"admin.users.cloneCatalogDesc": "管理者アカウントのまま操作します。元データは変更されません。",
"admin.users.cloneCatalogFrom": "コピー元: {name}",
"admin.users.cloneCatalogInto": "コピー先: {name}",
"admin.users.cloneCatalogWarning": "コピー先のカテゴリ、フィード、商品、属性、マッピング、ブランドを置き換えます。プラン、請求、メンバー、APIキー、ストア連携はコピーされません。",
"admin.users.cloneCatalogCancel": "キャンセル",
"admin.users.cloneCatalogConfirm": "カタログをコピー",
"admin.users.cloneDestFallback": "サンドボックス会社",
"admin.users.noUsers": "このフィルタに一致するユーザーはいません。", "admin.users.noUsers": "このフィルタに一致するユーザーはいません。",
"admin.users.noCompanies": "このフィルタに一致する会社はありません。", "admin.users.noCompanies": "このフィルタに一致する会社はありません。",
"admin.users.assignRoleTitle": "スタッフロールを割り当て", "admin.users.assignRoleTitle": "スタッフロールを割り当て",
@@ -2285,6 +2295,7 @@ export const ja: MessageDict = {
"flash.processing.retried": "「{name}」を再試行キューに入れました。", "flash.processing.retried": "「{name}」を再試行キューに入れました。",
"flash.admin.staffRoleUpdated": "{email} のスタッフロールを更新しました。", "flash.admin.staffRoleUpdated": "{email} のスタッフロールを更新しました。",
"flash.admin.staffRoleUnavailable": "このサーバーではスタッフロールの更新はまだ利用できません。", "flash.admin.staffRoleUnavailable": "このサーバーではスタッフロールの更新はまだ利用できません。",
"flash.admin.catalogCloned": "{source} を {dest} にコピーしました: 商品 {products}、カテゴリ {categories}。",
"flash.admin.planAssigned": "{name} にプランを割り当てました。", "flash.admin.planAssigned": "{name} にプランを割り当てました。",
"flash.admin.planAssignedShort": "プランを割り当てました。", "flash.admin.planAssignedShort": "プランを割り当てました。",
"flash.admin.creditsUpdated": "クレジットを更新しました。", "flash.admin.creditsUpdated": "クレジットを更新しました。",
@@ -3387,6 +3398,7 @@ export const ja: MessageDict = {
"billing.needCapacity": "容量が足りませんか?", "billing.needCapacity": "容量が足りませんか?",
"billing.selfServeUpgrade": "Starter、Growth、または Business へのセルフサービスアップグレード", "billing.selfServeUpgrade": "Starter、Growth、または Business へのセルフサービスアップグレード",
"billing.viaStripe": "Stripe経由", "billing.viaStripe": "Stripe経由",
"billing.checkoutUnavailable": "Stripe の設定が完了するまでセルフサービス購入は利用できません。プランについては営業にお問い合わせください。",
"billing.upgradeToGrowth": "Growthにアップグレード", "billing.upgradeToGrowth": "Growthにアップグレード",
"billing.askAdminPlan": "会社管理者にプラン変更を依頼してください。", "billing.askAdminPlan": "会社管理者にプラン変更を依頼してください。",
"billing.usage": "利用状況", "billing.usage": "利用状況",
@@ -4821,6 +4833,7 @@ export const ja: MessageDict = {
"plans.sub.creditsSuffix": "Checkout で Starter → Growth → Business にアップグレードするか、Enterprise については営業にお問い合わせください。", "plans.sub.creditsSuffix": "Checkout で Starter → Growth → Business にアップグレードするか、Enterprise については営業にお問い合わせください。",
"plans.sub.none": "まだプランが割り当てられていません(例:スキップされた移行後)。下からプランを選択してください — Checkout または管理者が割り当てるまで、容量は無制限ではありません。", "plans.sub.none": "まだプランが割り当てられていません(例:スキップされた移行後)。下からプランを選択してください — Checkout または管理者が割り当てるまで、容量は無制限ではありません。",
"plans.stripeHint": "セルフサービスのアップグレードは Stripe Checkout を使用します。", "plans.stripeHint": "セルフサービスのアップグレードは Stripe Checkout を使用します。",
"plans.checkoutUnavailable": "Stripe の設定が完了するまでセルフサービス Checkout は利用できません。プランについては営業にお問い合わせください。",
"plans.fallbackName": "プラン", "plans.fallbackName": "プラン",
"plans.fallbackDescription": "カタログ向けの容量", "plans.fallbackDescription": "カタログ向けの容量",
"plans.badge.current": "現在", "plans.badge.current": "現在",
+13
View File
@@ -802,6 +802,16 @@ export const nl: MessageDict = {
"admin.users.colCredits": "Credits", "admin.users.colCredits": "Credits",
"admin.users.colLanguage": "Taal", "admin.users.colLanguage": "Taal",
"admin.users.assignPlan": "Plan toewijzen", "admin.users.assignPlan": "Plan toewijzen",
"admin.users.cloneCatalog": "Kopiëren naar mijn bedrijf",
"admin.users.cloneCatalogAria": "Catalogus van {name} naar je sandboxbedrijf kopiëren",
"admin.users.cloneCatalogTitle": "Bedrijfscatalogus kopiëren",
"admin.users.cloneCatalogDesc": "Je blijft op je admin-account. Brongegevens worden niet gewijzigd.",
"admin.users.cloneCatalogFrom": "Bron: {name}",
"admin.users.cloneCatalogInto": "Bestemming: {name}",
"admin.users.cloneCatalogWarning": "Vervangt categorieën, feeds, producten, attributen, mappings en merk op de bestemming. Plan, facturatie, leden, API-sleutels en store-connectors worden niet gekopieerd.",
"admin.users.cloneCatalogCancel": "Annuleren",
"admin.users.cloneCatalogConfirm": "Catalogus kopiëren",
"admin.users.cloneDestFallback": "je sandboxbedrijf",
"admin.users.noUsers": "Geen gebruikers komen overeen met dit filter.", "admin.users.noUsers": "Geen gebruikers komen overeen met dit filter.",
"admin.users.noCompanies": "Geen bedrijven komen overeen met dit filter.", "admin.users.noCompanies": "Geen bedrijven komen overeen met dit filter.",
"admin.users.assignRoleTitle": "Medewerkerrol toewijzen", "admin.users.assignRoleTitle": "Medewerkerrol toewijzen",
@@ -2285,6 +2295,7 @@ export const nl: MessageDict = {
"flash.processing.retried": "\"{name}\" in de wachtrij voor opnieuw proberen.", "flash.processing.retried": "\"{name}\" in de wachtrij voor opnieuw proberen.",
"flash.admin.staffRoleUpdated": "Personeelsrol bijgewerkt voor {email}.", "flash.admin.staffRoleUpdated": "Personeelsrol bijgewerkt voor {email}.",
"flash.admin.staffRoleUnavailable": "Personeelsrolupdates zijn op deze server nog niet beschikbaar.", "flash.admin.staffRoleUnavailable": "Personeelsrolupdates zijn op deze server nog niet beschikbaar.",
"flash.admin.catalogCloned": "{source} gekopieerd naar {dest}: {products} producten, {categories} categorieën.",
"flash.admin.planAssigned": "Plan toegewezen aan {name}.", "flash.admin.planAssigned": "Plan toegewezen aan {name}.",
"flash.admin.planAssignedShort": "Plan toegewezen.", "flash.admin.planAssignedShort": "Plan toegewezen.",
"flash.admin.creditsUpdated": "Credits bijgewerkt.", "flash.admin.creditsUpdated": "Credits bijgewerkt.",
@@ -3387,6 +3398,7 @@ export const nl: MessageDict = {
"billing.needCapacity": "Meer capaciteit nodig?", "billing.needCapacity": "Meer capaciteit nodig?",
"billing.selfServeUpgrade": "Self-service-upgrade naar Starter, Growth of Business", "billing.selfServeUpgrade": "Self-service-upgrade naar Starter, Growth of Business",
"billing.viaStripe": "via Stripe", "billing.viaStripe": "via Stripe",
"billing.checkoutUnavailable": "Self-serviceaankopen zijn niet beschikbaar totdat Stripe is geconfigureerd. Neem contact op met sales voor een abonnement.",
"billing.upgradeToGrowth": "Upgraden naar Growth", "billing.upgradeToGrowth": "Upgraden naar Growth",
"billing.askAdminPlan": "Vraag een bedrijfsbeheerder om het plan te wijzigen.", "billing.askAdminPlan": "Vraag een bedrijfsbeheerder om het plan te wijzigen.",
"billing.usage": "Gebruik", "billing.usage": "Gebruik",
@@ -4821,6 +4833,7 @@ export const nl: MessageDict = {
"plans.sub.creditsSuffix": "Upgrade Starter → Growth → Business via Checkout, of neem contact op met sales voor Enterprise.", "plans.sub.creditsSuffix": "Upgrade Starter → Growth → Business via Checkout, of neem contact op met sales voor Enterprise.",
"plans.sub.none": "Er is nog geen abonnement toegewezen (bijvoorbeeld na een overgeslagen migratie). Kies hieronder een abonnement — de capaciteit is niet Onbeperkt totdat Checkout of een beheerder er een toewijst.", "plans.sub.none": "Er is nog geen abonnement toegewezen (bijvoorbeeld na een overgeslagen migratie). Kies hieronder een abonnement — de capaciteit is niet Onbeperkt totdat Checkout of een beheerder er een toewijst.",
"plans.stripeHint": "Zelfbedieningsupgrades gebruiken Stripe Checkout.", "plans.stripeHint": "Zelfbedieningsupgrades gebruiken Stripe Checkout.",
"plans.checkoutUnavailable": "Self-service-checkout is niet beschikbaar totdat Stripe is geconfigureerd. Neem contact op met sales voor een abonnement.",
"plans.fallbackName": "Abonnement", "plans.fallbackName": "Abonnement",
"plans.fallbackDescription": "Capaciteit voor uw catalogus", "plans.fallbackDescription": "Capaciteit voor uw catalogus",
"plans.badge.current": "Huidig", "plans.badge.current": "Huidig",
+13
View File
@@ -802,6 +802,16 @@ export const pl: MessageDict = {
"admin.users.colCredits": "Kredyty", "admin.users.colCredits": "Kredyty",
"admin.users.colLanguage": "Język", "admin.users.colLanguage": "Język",
"admin.users.assignPlan": "Przypisz plan", "admin.users.assignPlan": "Przypisz plan",
"admin.users.cloneCatalog": "Kopiuj do mojej firmy",
"admin.users.cloneCatalogAria": "Skopiuj katalog {name} do firmy sandbox",
"admin.users.cloneCatalogTitle": "Kopiuj katalog firmy",
"admin.users.cloneCatalogDesc": "Pozostajesz na koncie admina. Dane źródłowe nie są zmieniane.",
"admin.users.cloneCatalogFrom": "Źródło: {name}",
"admin.users.cloneCatalogInto": "Cel: {name}",
"admin.users.cloneCatalogWarning": "Zastępuje kategorie, feedy, produkty, atrybuty, mapowania i branding w celu. Plan, rozliczenia, członkowie, klucze API i konektory sklepu nie są kopiowane.",
"admin.users.cloneCatalogCancel": "Anuluj",
"admin.users.cloneCatalogConfirm": "Kopiuj katalog",
"admin.users.cloneDestFallback": "twoja firma sandbox",
"admin.users.noUsers": "Żaden użytkownik nie pasuje do tego filtra.", "admin.users.noUsers": "Żaden użytkownik nie pasuje do tego filtra.",
"admin.users.noCompanies": "Żadna firma nie pasuje do tego filtra.", "admin.users.noCompanies": "Żadna firma nie pasuje do tego filtra.",
"admin.users.assignRoleTitle": "Przypisz rolę personelu", "admin.users.assignRoleTitle": "Przypisz rolę personelu",
@@ -2285,6 +2295,7 @@ export const pl: MessageDict = {
"flash.processing.retried": "\"{name}\" w kolejce do ponowienia.", "flash.processing.retried": "\"{name}\" w kolejce do ponowienia.",
"flash.admin.staffRoleUpdated": "Zaktualizowano rolę personelu dla {email}.", "flash.admin.staffRoleUpdated": "Zaktualizowano rolę personelu dla {email}.",
"flash.admin.staffRoleUnavailable": "Aktualizacje ról personelu nie są jeszcze dostępne na tym serwerze.", "flash.admin.staffRoleUnavailable": "Aktualizacje ról personelu nie są jeszcze dostępne na tym serwerze.",
"flash.admin.catalogCloned": "Skopiowano {source} do {dest}: {products} produktów, {categories} kategorii.",
"flash.admin.planAssigned": "Przypisano plan do {name}.", "flash.admin.planAssigned": "Przypisano plan do {name}.",
"flash.admin.planAssignedShort": "Przypisano plan.", "flash.admin.planAssignedShort": "Przypisano plan.",
"flash.admin.creditsUpdated": "Zaktualizowano kredyty.", "flash.admin.creditsUpdated": "Zaktualizowano kredyty.",
@@ -3387,6 +3398,7 @@ export const pl: MessageDict = {
"billing.needCapacity": "Potrzebujesz większej pojemności?", "billing.needCapacity": "Potrzebujesz większej pojemności?",
"billing.selfServeUpgrade": "Samodzielne ulepszenie do Starter, Growth lub Business", "billing.selfServeUpgrade": "Samodzielne ulepszenie do Starter, Growth lub Business",
"billing.viaStripe": "przez Stripe", "billing.viaStripe": "przez Stripe",
"billing.checkoutUnavailable": "Zakupy samoobslugowe sa niedostepne, dopoki Stripe nie zostanie skonfigurowany. Skontaktuj sie ze sprzedaza, aby uzyskac plan.",
"billing.upgradeToGrowth": "Ulepsz do Growth", "billing.upgradeToGrowth": "Ulepsz do Growth",
"billing.askAdminPlan": "Poproś administratora firmy o zmianę planu.", "billing.askAdminPlan": "Poproś administratora firmy o zmianę planu.",
"billing.usage": "Użycie", "billing.usage": "Użycie",
@@ -4821,6 +4833,7 @@ export const pl: MessageDict = {
"plans.sub.creditsSuffix": "Ulepsz Starter → Growth → Business przez Checkout lub skontaktuj się z działem sprzedaży w sprawie Enterprise.", "plans.sub.creditsSuffix": "Ulepsz Starter → Growth → Business przez Checkout lub skontaktuj się z działem sprzedaży w sprawie Enterprise.",
"plans.sub.none": "Nie przypisano jeszcze planu (np. po pominiętej migracji). Wybierz plan poniżej — pojemność nie jest Nieograniczona, dopóki Checkout lub administrator nie przypisze planu.", "plans.sub.none": "Nie przypisano jeszcze planu (np. po pominiętej migracji). Wybierz plan poniżej — pojemność nie jest Nieograniczona, dopóki Checkout lub administrator nie przypisze planu.",
"plans.stripeHint": "Upgrade'y samoobsługowe korzystają ze Stripe Checkout.", "plans.stripeHint": "Upgrade'y samoobsługowe korzystają ze Stripe Checkout.",
"plans.checkoutUnavailable": "Checkout samoobslugowy jest niedostepny, dopoki Stripe nie zostanie skonfigurowany. Skontaktuj sie ze sprzedaza, aby uzyskac plan.",
"plans.fallbackName": "Plan taryfowy", "plans.fallbackName": "Plan taryfowy",
"plans.fallbackDescription": "Pojemność dla Twojego katalogu", "plans.fallbackDescription": "Pojemność dla Twojego katalogu",
"plans.badge.current": "Aktualny", "plans.badge.current": "Aktualny",
+13
View File
@@ -802,6 +802,16 @@ export const pt: MessageDict = {
"admin.users.colCredits": "Créditos", "admin.users.colCredits": "Créditos",
"admin.users.colLanguage": "Idioma", "admin.users.colLanguage": "Idioma",
"admin.users.assignPlan": "Atribuir plano", "admin.users.assignPlan": "Atribuir plano",
"admin.users.cloneCatalog": "Copiar para a minha empresa",
"admin.users.cloneCatalogAria": "Copiar o catálogo de {name} para a sua empresa sandbox",
"admin.users.cloneCatalogTitle": "Copiar catálogo da empresa",
"admin.users.cloneCatalogDesc": "Permanece na sua conta de admin. Os dados de origem não são alterados.",
"admin.users.cloneCatalogFrom": "Origem: {name}",
"admin.users.cloneCatalogInto": "Destino: {name}",
"admin.users.cloneCatalogWarning": "Substitui categorias, feeds, produtos, atributos, mappings e marca no destino. Plano, faturação, membros, chaves API e conectores de loja não são copiados.",
"admin.users.cloneCatalogCancel": "Cancelar",
"admin.users.cloneCatalogConfirm": "Copiar catálogo",
"admin.users.cloneDestFallback": "a sua empresa sandbox",
"admin.users.noUsers": "Nenhum utilizador corresponde a este filtro.", "admin.users.noUsers": "Nenhum utilizador corresponde a este filtro.",
"admin.users.noCompanies": "Nenhuma empresa corresponde a este filtro.", "admin.users.noCompanies": "Nenhuma empresa corresponde a este filtro.",
"admin.users.assignRoleTitle": "Atribuir função de equipa", "admin.users.assignRoleTitle": "Atribuir função de equipa",
@@ -2285,6 +2295,7 @@ export const pt: MessageDict = {
"flash.processing.retried": "\"{name}\" em fila para nova tentativa.", "flash.processing.retried": "\"{name}\" em fila para nova tentativa.",
"flash.admin.staffRoleUpdated": "Função de pessoal atualizada para {email}.", "flash.admin.staffRoleUpdated": "Função de pessoal atualizada para {email}.",
"flash.admin.staffRoleUnavailable": "As atualizações de função de pessoal ainda não estão disponíveis neste servidor.", "flash.admin.staffRoleUnavailable": "As atualizações de função de pessoal ainda não estão disponíveis neste servidor.",
"flash.admin.catalogCloned": "Copiado {source} para {dest}: {products} produtos, {categories} categorias.",
"flash.admin.planAssigned": "Plano atribuído a {name}.", "flash.admin.planAssigned": "Plano atribuído a {name}.",
"flash.admin.planAssignedShort": "Plano atribuído.", "flash.admin.planAssignedShort": "Plano atribuído.",
"flash.admin.creditsUpdated": "Créditos atualizados.", "flash.admin.creditsUpdated": "Créditos atualizados.",
@@ -3387,6 +3398,7 @@ export const pt: MessageDict = {
"billing.needCapacity": "Precisa de mais capacidade?", "billing.needCapacity": "Precisa de mais capacidade?",
"billing.selfServeUpgrade": "Atualização self-service para Starter, Growth ou Business", "billing.selfServeUpgrade": "Atualização self-service para Starter, Growth ou Business",
"billing.viaStripe": "via Stripe", "billing.viaStripe": "via Stripe",
"billing.checkoutUnavailable": "As compras self-service nao estao disponiveis ate o Stripe ser configurado. Contacte as vendas para um plano.",
"billing.upgradeToGrowth": "Atualizar para Growth", "billing.upgradeToGrowth": "Atualizar para Growth",
"billing.askAdminPlan": "Peça a um administrador da empresa para alterar o plano.", "billing.askAdminPlan": "Peça a um administrador da empresa para alterar o plano.",
"billing.usage": "Uso", "billing.usage": "Uso",
@@ -4821,6 +4833,7 @@ export const pt: MessageDict = {
"plans.sub.creditsSuffix": "Melhore Starter → Growth → Business com Checkout, ou fale com as vendas para Enterprise.", "plans.sub.creditsSuffix": "Melhore Starter → Growth → Business com Checkout, ou fale com as vendas para Enterprise.",
"plans.sub.none": "Ainda não há um plano atribuído (por exemplo após uma migração omitida). Escolha um plano abaixo — a capacidade não é Ilimitada até que o Checkout ou um administrador atribua um.", "plans.sub.none": "Ainda não há um plano atribuído (por exemplo após uma migração omitida). Escolha um plano abaixo — a capacidade não é Ilimitada até que o Checkout ou um administrador atribua um.",
"plans.stripeHint": "Os upgrades de autosserviço utilizam o Stripe Checkout.", "plans.stripeHint": "Os upgrades de autosserviço utilizam o Stripe Checkout.",
"plans.checkoutUnavailable": "O checkout self-service nao esta disponivel ate o Stripe ser configurado. Contacte as vendas para um plano.",
"plans.fallbackName": "Plano", "plans.fallbackName": "Plano",
"plans.fallbackDescription": "Capacidade para o seu catálogo", "plans.fallbackDescription": "Capacidade para o seu catálogo",
"plans.badge.current": "Atual", "plans.badge.current": "Atual",
+11
View File
@@ -19,6 +19,17 @@ export type StripeStatus = {
subscription_status?: string; subscription_status?: string;
}; };
/** Live Stripe for all company admins; mock checkout only for platform admins. */
export function canSelfServePurchase(
stripe: StripeStatus | null | undefined,
opts?: { isPlatformAdmin?: boolean }
): boolean {
if (!stripe) return false;
if (stripe.configured && !stripe.mock) return true;
if (stripe.mock && opts?.isPlatformAdmin) return true;
return false;
}
export type CheckoutResult = { export type CheckoutResult = {
url: string; url: string;
mock?: boolean; mock?: boolean;
+119 -13
View File
@@ -10,6 +10,7 @@
PAGE_SIZE, PAGE_SIZE,
STAFF_ROLE_OPTIONS, STAFF_ROLE_OPTIONS,
assignAdminPlan, assignAdminPlan,
cloneAdminCompanyCatalog,
companyPlanBadge, companyPlanBadge,
isStaffRoleApiUnavailable, isStaffRoleApiUnavailable,
listAdminCompanies, listAdminCompanies,
@@ -24,6 +25,7 @@
type AdminOrgUser, type AdminOrgUser,
type PlatformStaffRole type PlatformStaffRole
} from "$lib/admin-orgs"; } from "$lib/admin-orgs";
import { authSession } from "$lib/auth-session.svelte";
import PageShell from "$lib/components/PageShell.svelte"; import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte"; import Alert from "$lib/components/Alert.svelte";
import Spinner from "$lib/components/Spinner.svelte"; import Spinner from "$lib/components/Spinner.svelte";
@@ -52,7 +54,7 @@
TabsList, TabsList,
TabsTrigger TabsTrigger
} from "$lib/components/ui"; } from "$lib/components/ui";
import { Building2, KeyRound, Search, Shield, UserPlus, Users } from "@lucide/svelte"; import { Building2, Copy, KeyRound, Search, Shield, UserPlus, Users } from "@lucide/svelte";
type TabKey = "users" | "companies"; type TabKey = "users" | "companies";
@@ -88,11 +90,22 @@
let assignCompany = $state<AdminOrgCompany | null>(null); let assignCompany = $state<AdminOrgCompany | null>(null);
let assignPlanId = $state(""); let assignPlanId = $state("");
let cloneOpen = $state(false);
let cloneCompany = $state<AdminOrgCompany | null>(null);
const usersPage = $derived(Math.floor(usersOffset / PAGE_SIZE) + 1); const usersPage = $derived(Math.floor(usersOffset / PAGE_SIZE) + 1);
const usersPages = $derived(Math.max(1, Math.ceil(usersTotal / PAGE_SIZE))); const usersPages = $derived(Math.max(1, Math.ceil(usersTotal / PAGE_SIZE)));
const companiesPage = $derived(Math.floor(companiesOffset / PAGE_SIZE) + 1); const companiesPage = $derived(Math.floor(companiesOffset / PAGE_SIZE) + 1);
const companiesPages = $derived(Math.max(1, Math.ceil(companiesTotal / PAGE_SIZE))); const companiesPages = $derived(Math.max(1, Math.ceil(companiesTotal / PAGE_SIZE)));
const cloneDestLabel = $derived.by(() => {
const me = authSession.me;
const home = me?.staff_home_company;
if (home?.name) return home.name;
if (me?.company?.name) return me.company.name;
return i18n.t("admin.users.cloneDestFallback");
});
onMount(async () => { onMount(async () => {
const gate = await requirePlatformAdmin(); const gate = await requirePlatformAdmin();
if (!gate.ok) { if (!gate.ok) {
@@ -342,6 +355,44 @@
} }
} }
function openCloneDialog(company: AdminOrgCompany) {
cloneCompany = company;
cloneOpen = true;
error = "";
success = "";
}
async function confirmCloneCatalog() {
if (!cloneCompany) return;
busy = true;
error = "";
success = "";
try {
const me = authSession.me;
const destId =
me?.staff_home_company_id?.trim() ||
me?.staff_home_company?.id?.trim() ||
undefined;
const res = await cloneAdminCompanyCatalog(cloneCompany.id, {
destCompanyId: destId
});
const products = Number(res.counts?.raw_products ?? 0);
const categories = Number(res.counts?.categories ?? 0);
success = i18n.t("flash.admin.catalogCloned", {
source: cloneCompany.name,
dest: cloneDestLabel,
products: String(products),
categories: String(categories)
});
cloneOpen = false;
cloneCompany = null;
} catch (err) {
error = failureMessage(err, "Clone catalog failed");
} finally {
busy = false;
}
}
async function sendSetPasswordEmails(userId?: string) { async function sendSetPasswordEmails(userId?: string) {
busy = true; busy = true;
error = ""; error = "";
@@ -712,14 +763,28 @@
</TableCell> </TableCell>
<TableCell class="hidden text-muted-foreground lg:table-cell">{company.language || "—"}</TableCell> <TableCell class="hidden text-muted-foreground lg:table-cell">{company.language || "—"}</TableCell>
<TableCell stickyRight> <TableCell stickyRight>
<Button <div class="flex flex-wrap items-center justify-end gap-1">
size="sm" <Button
onclick={() => openAssignDialog(company)} size="sm"
aria-label={`Assign plan to ${company.name}`} variant="outline"
> onclick={() => openCloneDialog(company)}
<UserPlus class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" /> aria-label={i18n.t("admin.users.cloneCatalogAria", {
<span class="hidden lg:inline">{i18n.t("admin.users.assignPlan")}</span> name: company.name
</Button> })}
data-testid="admin-clone-catalog"
>
<Copy class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
<span class="hidden lg:inline">{i18n.t("admin.users.cloneCatalog")}</span>
</Button>
<Button
size="sm"
onclick={() => openAssignDialog(company)}
aria-label={`Assign plan to ${company.name}`}
>
<UserPlus class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
<span class="hidden lg:inline">{i18n.t("admin.users.assignPlan")}</span>
</Button>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
{/each} {/each}
@@ -758,8 +823,8 @@
<Dialog <Dialog
bind:open={passwordOpen} bind:open={passwordOpen}
title="Set password" title={i18n.t("admin.users.setPasswordTitle")}
description="Force-set a login password for this user (works for fake/legacy emails that cannot receive invites)." description={i18n.t("admin.users.setPasswordDesc")}
> >
<form class="space-y-4" onsubmit={saveForcedPassword}> <form class="space-y-4" onsubmit={saveForcedPassword}>
{#if error} {#if error}
@@ -771,7 +836,7 @@
</p> </p>
{/if} {/if}
<div class="space-y-2"> <div class="space-y-2">
<Label for="forced-password">New password</Label> <Label for="forced-password">{i18n.t("admin.users.newPassword")}</Label>
<Input <Input
id="forced-password" id="forced-password"
type="password" type="password"
@@ -781,7 +846,9 @@
bind:value={passwordValue} bind:value={passwordValue}
/> />
</div> </div>
<Button type="submit" loading={busyUserId === passwordUser?.id}>Set password</Button> <Button type="submit" loading={busyUserId === passwordUser?.id}
>{i18n.t("admin.users.setPasswordSubmit")}</Button
>
</form> </form>
</Dialog> </Dialog>
@@ -838,3 +905,42 @@
<Button type="submit" loading={busy} disabled={!assignPlanId}>{i18n.t("admin.users.assign")}</Button> <Button type="submit" loading={busy} disabled={!assignPlanId}>{i18n.t("admin.users.assign")}</Button>
</form> </form>
</Dialog> </Dialog>
<Dialog
bind:open={cloneOpen}
title={i18n.t("admin.users.cloneCatalogTitle")}
description={i18n.t("admin.users.cloneCatalogDesc")}
>
<div class="space-y-4">
{#if error}
<p class="text-sm text-destructive" role="alert">{error}</p>
{/if}
{#if cloneCompany}
<p class="text-sm text-foreground">
{i18n.t("admin.users.cloneCatalogFrom", { name: cloneCompany.name })}
</p>
{/if}
<p class="text-sm text-muted-foreground">
{i18n.t("admin.users.cloneCatalogInto", { name: cloneDestLabel })}
</p>
<p class="text-xs text-muted-foreground">
{i18n.t("admin.users.cloneCatalogWarning")}
</p>
<div class="flex flex-wrap justify-end gap-2">
<Button
type="button"
variant="outline"
disabled={busy}
onclick={() => {
cloneOpen = false;
cloneCompany = null;
}}
>
{i18n.t("admin.users.cloneCatalogCancel")}
</Button>
<Button type="button" loading={busy} onclick={() => confirmCloneCatalog()}>
{i18n.t("admin.users.cloneCatalogConfirm")}
</Button>
</div>
</div>
</Dialog>
+10 -1
View File
@@ -37,6 +37,7 @@
startCheckout, startCheckout,
startCreditPackCheckout, startCreditPackCheckout,
redirectToCheckout, redirectToCheckout,
canSelfServePurchase,
type StripeStatus type StripeStatus
} from "$lib/stripe-billing"; } from "$lib/stripe-billing";
import { CREDIT_PACKS } from "$lib/components/pricing/credit-packs"; import { CREDIT_PACKS } from "$lib/components/pricing/credit-packs";
@@ -255,8 +256,12 @@
); );
const atProductLimit = $derived(Boolean(credits?.at_product_limit) && !enterprise); const atProductLimit = $derived(Boolean(credits?.at_product_limit) && !enterprise);
const nextUpgradePlan = $derived(nextSelfServeUpgradePlan(planName)); const nextUpgradePlan = $derived(nextSelfServeUpgradePlan(planName));
const purchasesEnabled = $derived(
canSelfServePurchase(stripe, { isPlatformAdmin })
);
const showQuickUpgrade = $derived( const showQuickUpgrade = $derived(
canManageBilling && canManageBilling &&
purchasesEnabled &&
!enterprise && !enterprise &&
!payg && !payg &&
!isLegacyPlan(plan) && !isLegacyPlan(plan) &&
@@ -450,6 +455,10 @@
<Alert message={error} /> <Alert message={error} />
<Alert tone="success" message={success} /> <Alert tone="success" message={success} />
{#if canManageBilling && !purchasesEnabled}
<Alert tone="info" message={i18n.t("billing.checkoutUnavailable")} />
{/if}
{#if recovery} {#if recovery}
<UpgradeBanner <UpgradeBanner
tone={recovery.tone} tone={recovery.tone}
@@ -615,7 +624,7 @@
</section> </section>
{/if} {/if}
{#if canManageBilling && !enterprise && planAssigned} {#if canManageBilling && purchasesEnabled && !enterprise && planAssigned}
<section class="space-y-3 rounded-lg border border-border bg-card px-4 py-4"> <section class="space-y-3 rounded-lg border border-border bg-card px-4 py-4">
<div> <div>
<h2 class="text-base font-semibold tracking-tight">{i18n.t("billing.buyCreditPacks")}</h2> <h2 class="text-base font-semibold tracking-tight">{i18n.t("billing.buyCreditPacks")}</h2>
+14 -2
View File
@@ -20,6 +20,7 @@
fetchStripeStatus, fetchStripeStatus,
redirectToCheckout, redirectToCheckout,
startCheckout, startCheckout,
canSelfServePurchase,
type StripeStatus type StripeStatus
} from "$lib/stripe-billing"; } from "$lib/stripe-billing";
import type { CreditBalance, MeResponse } from "$lib/types"; import type { CreditBalance, MeResponse } from "$lib/types";
@@ -74,6 +75,7 @@
let apiAvailable = $state(true); let apiAvailable = $state(true);
let checkoutBusy = $state<string | null>(null); let checkoutBusy = $state<string | null>(null);
let canManageBilling = $state(false); let canManageBilling = $state(false);
let isPlatformAdmin = $state(false);
function apiFor(name: string): ApiPlan | undefined { function apiFor(name: string): ApiPlan | undefined {
const key = name.trim().toLowerCase(); const key = name.trim().toLowerCase();
@@ -99,6 +101,7 @@
apiPlans = (plansPayload?.plans ?? []).filter((p) => isPublicProductPlan(p.name)); apiPlans = (plansPayload?.plans ?? []).filter((p) => isPublicProductPlan(p.name));
stripe = stripeRes; stripe = stripeRes;
canManageBilling = isCompanyAdmin(me); canManageBilling = isCompanyAdmin(me);
isPlatformAdmin = Boolean(me.user?.is_platform_admin);
const bal: CreditBalance | null = const bal: CreditBalance | null =
me.credits ?? (await api<CreditBalance>("/api/billing/credits").catch(() => null)); me.credits ?? (await api<CreditBalance>("/api/billing/credits").catch(() => null));
@@ -124,6 +127,7 @@
const currentName = $derived(hasPlan ? planNameOf(currentPlan).toLowerCase() : ""); const currentName = $derived(hasPlan ? planNameOf(currentPlan).toLowerCase() : "");
const remainingLabel = $derived(formatCreditsRemaining(remainingCredits, hasPlan ? currentPlan : null)); const remainingLabel = $derived(formatCreditsRemaining(remainingCredits, hasPlan ? currentPlan : null));
const currentIsPayg = $derived(hasPlan && isPayAsYouGoPlan(currentPlan)); const currentIsPayg = $derived(hasPlan && isPayAsYouGoPlan(currentPlan));
const purchasesEnabled = $derived(canSelfServePurchase(stripe, { isPlatformAdmin }));
const displayPlans = $derived.by(() => { const displayPlans = $derived.by(() => {
return PRICING_PLANS.map((marketing) => { return PRICING_PLANS.map((marketing) => {
@@ -152,10 +156,14 @@
ctaMode = "link"; ctaMode = "link";
ctaHref = "/billing"; ctaHref = "/billing";
} else if (selfServe) { } else if (selfServe) {
if (canManageBilling) { if (canManageBilling && purchasesEnabled) {
ctaText = i18n.t("plans.cta.upgradeTo", { name }); ctaText = i18n.t("plans.cta.upgradeTo", { name });
ctaMode = "checkout"; ctaMode = "checkout";
ctaHref = "/billing"; ctaHref = "/billing";
} else if (canManageBilling) {
ctaText = i18n.t("plans.cta.contactSales");
ctaMode = "link";
ctaHref = `${CONTACT_SALES_HREF}?source=plans`;
} else { } else {
ctaText = i18n.t("common.askAdmin"); ctaText = i18n.t("common.askAdmin");
ctaMode = "link"; ctaMode = "link";
@@ -238,6 +246,10 @@
<Alert tone="info" message={i18n.t("plans.apiUnavailable")} /> <Alert tone="info" message={i18n.t("plans.apiUnavailable")} />
{/if} {/if}
{#if canManageBilling && !purchasesEnabled}
<Alert tone="info" message={i18n.t("plans.checkoutUnavailable")} />
{/if}
<div class="space-y-4 text-center"> <div class="space-y-4 text-center">
<h1 class="text-4xl font-bold tracking-tight">{i18n.t("plans.title")}</h1> <h1 class="text-4xl font-bold tracking-tight">{i18n.t("plans.title")}</h1>
<p class="mx-auto max-w-2xl text-xl text-muted-foreground"> <p class="mx-auto max-w-2xl text-xl text-muted-foreground">
@@ -260,7 +272,7 @@
{i18n.t("plans.sub.none")} {i18n.t("plans.sub.none")}
{/if} {/if}
</p> </p>
{#if stripe?.configured && !stripe?.mock} {#if purchasesEnabled && stripe?.configured && !stripe?.mock}
<p class="text-sm text-muted-foreground">{i18n.t("plans.stripeHint")}</p> <p class="text-sm text-muted-foreground">{i18n.t("plans.stripeHint")}</p>
{/if} {/if}
</div> </div>