diff --git a/apps/api/internal/billing/client_errors.go b/apps/api/internal/billing/client_errors.go index 0e71855..b1030c9 100644 --- a/apps/api/internal/billing/client_errors.go +++ b/apps/api/internal/billing/client_errors.go @@ -18,6 +18,7 @@ func ClientError(err error) (msg string, ok bool) { errors.Is(err, ErrPlanNotFound), errors.Is(err, ErrAmountRequired), errors.Is(err, ErrStripeNotConfigured), + errors.Is(err, ErrStripeSelfServeUnavailable), errors.Is(err, ErrStripePlanUnsupported), errors.Is(err, ErrStripePriceMissing), errors.Is(err, ErrStripeNoCustomer), diff --git a/apps/api/internal/billing/stripe.go b/apps/api/internal/billing/stripe.go index 14aa7e1..d7dbdba 100644 --- a/apps/api/internal/billing/stripe.go +++ b/apps/api/internal/billing/stripe.go @@ -99,12 +99,32 @@ func (s *StripeService) cfg(ctx context.Context) StripeConfig { } var ( - ErrStripeNotConfigured = errors.New("stripe not configured") - ErrStripePlanUnsupported = errors.New("plan is not available for self-serve checkout") - ErrStripePriceMissing = errors.New("stripe price id not configured for plan/term") - ErrStripeBadSignature = errors.New("invalid stripe signature") + ErrStripeNotConfigured = errors.New("stripe not configured") + ErrStripeSelfServeUnavailable = errors.New("self-serve checkout is unavailable until Stripe is configured") + ErrStripePlanUnsupported = errors.New("plan is not available for self-serve checkout") + 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. // Set Pack for a one-time AI credit top-up, or Plan (+ Term) for a subscription. type CheckoutRequest struct { diff --git a/apps/api/internal/billing/stripe_test.go b/apps/api/internal/billing/stripe_test.go index 4300a36..f065a7b 100644 --- a/apps/api/internal/billing/stripe_test.go +++ b/apps/api/internal/billing/stripe_test.go @@ -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) { s := &StripeService{Cfg: StripeConfig{WebOrigin: "http://localhost:5174"}} _, err := s.CreateCheckoutSession(context.TODO(), uuid.Nil, "a@b.c", "Acme", CheckoutRequest{Plan: "starter", Term: "monthly"}) diff --git a/apps/api/internal/catalog/clone.go b/apps/api/internal/catalog/clone.go new file mode 100644 index 0000000..0ba7f16 --- /dev/null +++ b/apps/api/internal/catalog/clone.go @@ -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 +} diff --git a/apps/api/internal/catalog/clone_test.go b/apps/api/internal/catalog/clone_test.go new file mode 100644 index 0000000..3195d41 --- /dev/null +++ b/apps/api/internal/catalog/clone_test.go @@ -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) + } +} diff --git a/apps/api/internal/httpapi/admin_clone_catalog_handlers.go b/apps/api/internal/httpapi/admin_clone_catalog_handlers.go new file mode 100644 index 0000000..5baaf05 --- /dev/null +++ b/apps/api/internal/httpapi/admin_clone_catalog_handlers.go @@ -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") +} diff --git a/apps/api/internal/httpapi/admin_clone_catalog_handlers_test.go b/apps/api/internal/httpapi/admin_clone_catalog_handlers_test.go new file mode 100644 index 0000000..59e11f6 --- /dev/null +++ b/apps/api/internal/httpapi/admin_clone_catalog_handlers_test.go @@ -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()) + } +} diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index 2447372..f37d37d 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -387,9 +387,10 @@ func (s *Server) Router() http.Handler { r.Get("/users", s.handleAdminListUsers) r.Patch("/users/{id}/staff-role", s.handleAdminSetStaffRole) r.Put("/support/agents/{id}", s.handleAdminSetSupportAgent) - r.Get("/staff", s.handleAdminListStaff) - r.Post("/users/{id}/dev-password", s.handleAdminDevSetPassword) - r.Get("/companies", s.handleAdminListCompanies) + r.Get("/staff", s.handleAdminListStaff) + r.Post("/users/{id}/dev-password", s.handleAdminDevSetPassword) + r.Get("/companies", s.handleAdminListCompanies) + r.Post("/companies/{id}/clone-catalog", s.handleAdminCloneCompanyCatalog) r.Get("/readiness", s.handleAdminReadiness) r.Get("/diagnostics", s.handleAdminDiagnostics) r.Get("/analytics", s.handleAdminAnalytics) diff --git a/apps/api/internal/httpapi/stripe_handlers.go b/apps/api/internal/httpapi/stripe_handlers.go index 4c8d8b7..2dc2f39 100644 --- a/apps/api/internal/httpapi/stripe_handlers.go +++ b/apps/api/internal/httpapi/stripe_handlers.go @@ -45,6 +45,15 @@ func (s *Server) handleStripeCheckout(w http.ResponseWriter, r *http.Request) { Error(w, http.StatusForbidden, "admin required") 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 if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") diff --git a/apps/api/internal/httpapi/stripe_handlers_test.go b/apps/api/internal/httpapi/stripe_handlers_test.go index 759debc..3037ac0 100644 --- a/apps/api/internal/httpapi/stripe_handlers_test.go +++ b/apps/api/internal/httpapi/stripe_handlers_test.go @@ -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 { t.Helper() ts := time.Now().Unix() diff --git a/apps/web/src/lib/admin-orgs.ts b/apps/web/src/lib/admin-orgs.ts index 1531c41..db8f2d5 100644 --- a/apps/web/src/lib/admin-orgs.ts +++ b/apps/web/src/lib/admin-orgs.ts @@ -16,6 +16,8 @@ import { export const ADMIN_USERS_PATH = "/api/admin/users"; 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) => `/api/admin/users/${encodeURIComponent(userId)}/staff-role`; @@ -201,6 +203,28 @@ export async function setAdminStaffRole( return res.user; } +export type CloneCatalogResult = { + status: string; + source_company_id: string; + dest_company_id: string; + counts: Record; +}; + +/** Copy source company catalog into the admin sandbox (home / dest). Source is unchanged. */ +export async function cloneAdminCompanyCatalog( + sourceCompanyId: string, + opts?: { destCompanyId?: string } +): Promise { + const body: { confirm: true; dest_company_id?: string } = { confirm: true }; + if (opts?.destCompanyId?.trim()) { + body.dest_company_id = opts.destCompanyId.trim(); + } + return api(ADMIN_CLONE_CATALOG_PATH(sourceCompanyId), { + method: "POST", + body + }); +} + export function isStaffRoleApiUnavailable(err: unknown): boolean { return err instanceof ApiError && (err.status === 404 || err.status === 501); } diff --git a/apps/web/src/lib/components/products/ProductEditPanel.svelte b/apps/web/src/lib/components/products/ProductEditPanel.svelte index ab1dbb8..2c1881d 100644 --- a/apps/web/src/lib/components/products/ProductEditPanel.svelte +++ b/apps/web/src/lib/components/products/ProductEditPanel.svelte @@ -15,10 +15,12 @@ import { activateFocusTrap, type FocusTrapHandle } from "$lib/a11y/focus-trap"; import type { CategoryOption, ProductRow } from "./types"; import { + categoryFieldDisplayValue, enrichmentCardClass, enrichmentChipClass, enrichmentChipTitle, enrichmentStateLabel, + findCategoryOption, formatRelativeUpdated, isEnrichmentReviewStatus, productAttrEntries, @@ -174,6 +176,7 @@ void p.category; void p.category_unique_id; void p.category_name; + void categories; void p.product_id; void p.sku; void p.gtin; @@ -187,8 +190,11 @@ name = resolveOriginalName(p); description = resolveOriginalDescription(p); const rawCategory = String(p.category_unique_id ?? p.category ?? "").trim(); + const matched = findCategoryOption(categories, rawCategory) ?? findCategoryOption(categories, p.category); category = - rawCategory === "" || rawCategory.toLowerCase() === "none" ? "none" : rawCategory; + rawCategory === "" || rawCategory.toLowerCase() === "none" + ? "none" + : matched?.uniqueId || rawCategory; status = String(p.status ?? p.processing_status ?? ""); productId = String(p.product_id ?? p.sku ?? p.gtin ?? ""); const langs = @@ -425,13 +431,18 @@ return attrs.filter((row) => editable.get(row.key.toLowerCase()) !== row.value); }); const enrichment = $derived.by(() => (product ? productEnrichmentStates(product) : null)); - const feedFields = $derived.by(() => productFeedFieldSummary(product)); - const feedMapped = $derived.by(() => productFeedMappedEntries(product)); + const feedFields = $derived.by(() => productFeedFieldSummary(product, categories)); + const feedMapped = $derived.by(() => productFeedMappedEntries(product, categories)); const feedSync = $derived.by(() => (product ? productFeedSyncLabel(product) : "")); const feedFilledCount = $derived(feedMapped.filter((f) => f.ok).length); const coverageCount = $derived( 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); + } {#if open} @@ -741,16 +752,21 @@ bind:value={category} onchange={markDirty} > - - {#each categories as cat} - - {/each} - - - - {/if} + + {#each categories as cat} + + {/each} + {#if category !== "none" && !categories.some((c) => c.uniqueId === category)} + + {/if} + + + + {/if} - + {#if enrichment}
@@ -862,15 +878,20 @@ bind:value={category} onchange={markDirty} > - - {#each categories as cat} - - {/each} - -
- -
-
+ + {#each categories as cat} + + {/each} + {#if category !== "none" && !categories.some((c) => c.uniqueId === category)} + + {/if} + + + + +
@@ -1115,7 +1136,7 @@ {/if} {attr.value}{attrDisplayValue(attr.key, attr.value)} {/each} @@ -1133,7 +1154,9 @@ class="flex items-start justify-between gap-3 rounded-md border border-border px-3 py-2" > {attr.key} - {attr.value} + {attrDisplayValue(attr.key, attr.value)} {/each} @@ -1150,7 +1173,9 @@ class="flex items-start justify-between gap-3 rounded-md border border-border px-3 py-2" > {attr.key} - {attr.value} + {attrDisplayValue(attr.key, attr.value)} {/each} diff --git a/apps/web/src/lib/components/products/types.ts b/apps/web/src/lib/components/products/types.ts index 12e3af9..0b9b58d 100644 --- a/apps/web/src/lib/components/products/types.ts +++ b/apps/web/src/lib/components/products/types.ts @@ -126,7 +126,15 @@ export function categoryDisplayName( ): string { if (p && typeof p === "object") { 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(); if (!ref || ref.toLowerCase() === "none") return i18n.t("products.table.uncategorized"); return ( @@ -140,6 +148,34 @@ export function categoryDisplayName( 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 | 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 { const fallback = i18n.t("products.unnamed"); 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). */ -export function productFeedFieldSummary(product: ProductRow | null | undefined): { key: string; value: string; ok: boolean }[] { - return productFeedMappedEntries(product).filter((e) => e.preferred); +export function productFeedFieldSummary( + 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). */ @@ -523,7 +562,10 @@ export type FeedMappedEntry = { }; /** Full mapped_data inventory for the Feed tab (preferred keys first, then A–Z). */ -export function productFeedMappedEntries(product: ProductRow | null | undefined): FeedMappedEntry[] { +export function productFeedMappedEntries( + product: ProductRow | null | undefined, + options: CategoryOption[] = [] +): FeedMappedEntry[] { const mapped = asRecord(product?.mapped_data); if (!mapped) return []; const preferred = [ @@ -548,7 +590,10 @@ export function productFeedMappedEntries(product: ProductRow | null | undefined) return a.localeCompare(b); }); 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; return { key, diff --git a/apps/web/src/lib/i18n/messages/de.ts b/apps/web/src/lib/i18n/messages/de.ts index 6cad7b5..d31b50f 100644 --- a/apps/web/src/lib/i18n/messages/de.ts +++ b/apps/web/src/lib/i18n/messages/de.ts @@ -802,6 +802,16 @@ export const de: MessageDict = { "admin.users.colCredits": "Credits", "admin.users.colLanguage": "Sprache", "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.noCompanies": "Keine Unternehmen entsprechen diesem Filter.", "admin.users.assignRoleTitle": "Mitarbeiterrolle zuweisen", @@ -2286,6 +2296,7 @@ export const de: MessageDict = { "flash.admin.staffRoleUpdated": "Mitarbeiterrolle für {email} aktualisiert.", "flash.admin.staffRoleUnavailable": "Mitarbeiterrollen-Updates sind auf diesem Server noch nicht verfügbar.", "flash.admin.planAssigned": "Plan {name} zugewiesen.", + "flash.admin.catalogCloned": "{source} nach {dest} kopiert: {products} Produkte, {categories} Kategorien.", "flash.admin.planAssignedShort": "Plan zugewiesen.", "flash.admin.creditsUpdated": "Credits aktualisiert.", "flash.admin.cyclesProcessed": "Abrechnungszyklen verarbeitet: {count}", @@ -3387,6 +3398,7 @@ export const de: MessageDict = { "billing.needCapacity": "Mehr Kapazität nötig?", "billing.selfServeUpgrade": "Self-Service-Upgrade auf Starter, Growth oder Business", "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.askAdminPlan": "Bitten Sie einen Firmen-Admin, den Plan zu ändern.", "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.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.checkoutUnavailable": "Self-Service-Checkout ist nicht verfügbar, bis Stripe eingerichtet ist. Kontaktieren Sie den Vertrieb für einen Plan.", "plans.fallbackName": "Tarifplan", "plans.fallbackDescription": "Kapazität für Ihren Katalog", "plans.badge.current": "Aktuell", diff --git a/apps/web/src/lib/i18n/messages/en.ts b/apps/web/src/lib/i18n/messages/en.ts index d1dfa80..03e95d2 100644 --- a/apps/web/src/lib/i18n/messages/en.ts +++ b/apps/web/src/lib/i18n/messages/en.ts @@ -814,13 +814,27 @@ export const en: MessageDict = { "admin.users.mustSetPassword": "Must set password", "admin.users.inactive": "Inactive", "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.colCompany": "Company", "admin.users.colPlan": "Plan", "admin.users.colCredits": "Credits", "admin.users.colLanguage": "Language", "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.noCompanies": "No companies match this filter.", "admin.users.assignRoleTitle": "Assign staff role", @@ -2307,14 +2321,15 @@ export const en: MessageDict = { "flash.admin.staffRoleUpdated": "Staff role updated for {email}.", "flash.admin.staffRoleUnavailable": "Staff role updates are not available on this server yet.", "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.creditsUpdated": "Credits updated.", "flash.admin.cyclesProcessed": "Billing cycles processed: {count}", "flash.admin.invitesSummary": "Set-password invites: {parts}. Email delivery is {smtp}.", "flash.admin.smtpOn": "on", "flash.admin.smtpOff": "off", - "flash.admin.localPasswordSet": "Local password set for {email}.", - "flash.admin.localPasswordUnavailable": "Local password tools are not available in this environment.", + "flash.admin.localPasswordSet": "Password set for {email}.", + "flash.admin.localPasswordUnavailable": "Set-password tools are not available in this environment.", "flash.admin.switchedUser": "Switched user. Reloading…", "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.", @@ -3410,6 +3425,7 @@ export const en: MessageDict = { "billing.needCapacity": "Need more capacity?", "billing.selfServeUpgrade": "Self-serve upgrade to Starter, Growth, or Business", "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.askAdminPlan": "Ask a company admin to change the plan.", "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.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.checkoutUnavailable": "Self-serve checkout is unavailable until Stripe is configured. Contact sales to get a plan.", "plans.fallbackName": "Plan", "plans.fallbackDescription": "Capacity for your catalog", "plans.badge.current": "Current", diff --git a/apps/web/src/lib/i18n/messages/es.ts b/apps/web/src/lib/i18n/messages/es.ts index ee806d7..838bd7a 100644 --- a/apps/web/src/lib/i18n/messages/es.ts +++ b/apps/web/src/lib/i18n/messages/es.ts @@ -802,6 +802,16 @@ export const es: MessageDict = { "admin.users.colCredits": "Créditos", "admin.users.colLanguage": "Idioma", "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.noCompanies": "Ninguna empresa coincide con este filtro.", "admin.users.assignRoleTitle": "Asignar rol de personal", @@ -2285,6 +2295,7 @@ export const es: MessageDict = { "flash.processing.retried": "\"{name}\" en cola para reintento.", "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.catalogCloned": "Se copió {source} en {dest}: {products} productos, {categories} categorías.", "flash.admin.planAssigned": "Plan asignado a {name}.", "flash.admin.planAssignedShort": "Plan asignado.", "flash.admin.creditsUpdated": "Créditos actualizados.", @@ -3387,6 +3398,7 @@ export const es: MessageDict = { "billing.needCapacity": "¿Necesitas más capacidad?", "billing.selfServeUpgrade": "Mejora autoservicio a Starter, Growth o Business", "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.askAdminPlan": "Pide a un administrador de la empresa que cambie el plan.", "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.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.checkoutUnavailable": "El checkout de autoservicio no esta disponible hasta que Stripe este configurado. Contacta con ventas para un plan.", "plans.fallbackName": "Plan tarifario", "plans.fallbackDescription": "Capacidad para tu catálogo", "plans.badge.current": "Actual", diff --git a/apps/web/src/lib/i18n/messages/fr.ts b/apps/web/src/lib/i18n/messages/fr.ts index 9e777d2..56cad42 100644 --- a/apps/web/src/lib/i18n/messages/fr.ts +++ b/apps/web/src/lib/i18n/messages/fr.ts @@ -801,7 +801,17 @@ export const fr: MessageDict = { "admin.users.colPlan": "Offre", "admin.users.colCredits": "Crédits", "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.noCompanies": "Aucune entreprise ne correspond à ce filtre.", "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.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.catalogCloned": "{source} copié vers {dest} : {products} produits, {categories} catégories.", "flash.admin.planAssigned": "Offre assignée à {name}.", "flash.admin.planAssignedShort": "Offre assignée.", "flash.admin.creditsUpdated": "Crédits mis à jour.", @@ -3387,6 +3398,7 @@ export const fr: MessageDict = { "billing.needCapacity": "Besoin de plus de capacité ?", "billing.selfServeUpgrade": "Mise à niveau en libre-service vers Starter, Growth ou Business", "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.askAdminPlan": "Demandez à un administrateur de changer le plan.", "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.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.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.fallbackDescription": "Capacité pour votre catalogue", "plans.badge.current": "Actuel", diff --git a/apps/web/src/lib/i18n/messages/it.ts b/apps/web/src/lib/i18n/messages/it.ts index 7ff7b50..acb23b5 100644 --- a/apps/web/src/lib/i18n/messages/it.ts +++ b/apps/web/src/lib/i18n/messages/it.ts @@ -802,6 +802,16 @@ export const it: MessageDict = { "admin.users.colCredits": "Crediti", "admin.users.colLanguage": "Lingua", "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.noCompanies": "Nessuna azienda corrisponde a questo filtro.", "admin.users.assignRoleTitle": "Assegna ruolo staff", @@ -2285,6 +2295,7 @@ export const it: MessageDict = { "flash.processing.retried": "\"{name}\" messo in coda per riprovare.", "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.catalogCloned": "Copiato {source} in {dest}: {products} prodotti, {categories} categorie.", "flash.admin.planAssigned": "Piano assegnato a {name}.", "flash.admin.planAssignedShort": "Piano assegnato.", "flash.admin.creditsUpdated": "Crediti aggiornati.", @@ -3387,6 +3398,7 @@ export const it: MessageDict = { "billing.needCapacity": "Serve più capacità?", "billing.selfServeUpgrade": "Upgrade self-service a Starter, Growth o Business", "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.askAdminPlan": "Chiedi a un amministratore di cambiare il piano.", "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.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.checkoutUnavailable": "Il checkout self-service non e disponibile finche Stripe non e configurato. Contatta le vendite per un piano.", "plans.fallbackName": "Piano", "plans.fallbackDescription": "Capacità per il tuo catalogo", "plans.badge.current": "Attuale", diff --git a/apps/web/src/lib/i18n/messages/ja.ts b/apps/web/src/lib/i18n/messages/ja.ts index dac949c..7bdc554 100644 --- a/apps/web/src/lib/i18n/messages/ja.ts +++ b/apps/web/src/lib/i18n/messages/ja.ts @@ -802,6 +802,16 @@ export const ja: MessageDict = { "admin.users.colCredits": "クレジット", "admin.users.colLanguage": "言語", "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.noCompanies": "このフィルタに一致する会社はありません。", "admin.users.assignRoleTitle": "スタッフロールを割り当て", @@ -2285,6 +2295,7 @@ export const ja: MessageDict = { "flash.processing.retried": "「{name}」を再試行キューに入れました。", "flash.admin.staffRoleUpdated": "{email} のスタッフロールを更新しました。", "flash.admin.staffRoleUnavailable": "このサーバーではスタッフロールの更新はまだ利用できません。", + "flash.admin.catalogCloned": "{source} を {dest} にコピーしました: 商品 {products}、カテゴリ {categories}。", "flash.admin.planAssigned": "{name} にプランを割り当てました。", "flash.admin.planAssignedShort": "プランを割り当てました。", "flash.admin.creditsUpdated": "クレジットを更新しました。", @@ -3387,6 +3398,7 @@ export const ja: MessageDict = { "billing.needCapacity": "容量が足りませんか?", "billing.selfServeUpgrade": "Starter、Growth、または Business へのセルフサービスアップグレード", "billing.viaStripe": "Stripe経由", + "billing.checkoutUnavailable": "Stripe の設定が完了するまでセルフサービス購入は利用できません。プランについては営業にお問い合わせください。", "billing.upgradeToGrowth": "Growthにアップグレード", "billing.askAdminPlan": "会社管理者にプラン変更を依頼してください。", "billing.usage": "利用状況", @@ -4821,6 +4833,7 @@ export const ja: MessageDict = { "plans.sub.creditsSuffix": "Checkout で Starter → Growth → Business にアップグレードするか、Enterprise については営業にお問い合わせください。", "plans.sub.none": "まだプランが割り当てられていません(例:スキップされた移行後)。下からプランを選択してください — Checkout または管理者が割り当てるまで、容量は無制限ではありません。", "plans.stripeHint": "セルフサービスのアップグレードは Stripe Checkout を使用します。", + "plans.checkoutUnavailable": "Stripe の設定が完了するまでセルフサービス Checkout は利用できません。プランについては営業にお問い合わせください。", "plans.fallbackName": "プラン", "plans.fallbackDescription": "カタログ向けの容量", "plans.badge.current": "現在", diff --git a/apps/web/src/lib/i18n/messages/nl.ts b/apps/web/src/lib/i18n/messages/nl.ts index 5430448..f16cef0 100644 --- a/apps/web/src/lib/i18n/messages/nl.ts +++ b/apps/web/src/lib/i18n/messages/nl.ts @@ -802,6 +802,16 @@ export const nl: MessageDict = { "admin.users.colCredits": "Credits", "admin.users.colLanguage": "Taal", "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.noCompanies": "Geen bedrijven komen overeen met dit filter.", "admin.users.assignRoleTitle": "Medewerkerrol toewijzen", @@ -2285,6 +2295,7 @@ export const nl: MessageDict = { "flash.processing.retried": "\"{name}\" in de wachtrij voor opnieuw proberen.", "flash.admin.staffRoleUpdated": "Personeelsrol bijgewerkt voor {email}.", "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.planAssignedShort": "Plan toegewezen.", "flash.admin.creditsUpdated": "Credits bijgewerkt.", @@ -3387,6 +3398,7 @@ export const nl: MessageDict = { "billing.needCapacity": "Meer capaciteit nodig?", "billing.selfServeUpgrade": "Self-service-upgrade naar Starter, Growth of Business", "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.askAdminPlan": "Vraag een bedrijfsbeheerder om het plan te wijzigen.", "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.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.checkoutUnavailable": "Self-service-checkout is niet beschikbaar totdat Stripe is geconfigureerd. Neem contact op met sales voor een abonnement.", "plans.fallbackName": "Abonnement", "plans.fallbackDescription": "Capaciteit voor uw catalogus", "plans.badge.current": "Huidig", diff --git a/apps/web/src/lib/i18n/messages/pl.ts b/apps/web/src/lib/i18n/messages/pl.ts index 56e8638..a5987df 100644 --- a/apps/web/src/lib/i18n/messages/pl.ts +++ b/apps/web/src/lib/i18n/messages/pl.ts @@ -802,6 +802,16 @@ export const pl: MessageDict = { "admin.users.colCredits": "Kredyty", "admin.users.colLanguage": "Język", "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.noCompanies": "Żadna firma nie pasuje do tego filtra.", "admin.users.assignRoleTitle": "Przypisz rolę personelu", @@ -2285,6 +2295,7 @@ export const pl: MessageDict = { "flash.processing.retried": "\"{name}\" w kolejce do ponowienia.", "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.catalogCloned": "Skopiowano {source} do {dest}: {products} produktów, {categories} kategorii.", "flash.admin.planAssigned": "Przypisano plan do {name}.", "flash.admin.planAssignedShort": "Przypisano plan.", "flash.admin.creditsUpdated": "Zaktualizowano kredyty.", @@ -3387,6 +3398,7 @@ export const pl: MessageDict = { "billing.needCapacity": "Potrzebujesz większej pojemności?", "billing.selfServeUpgrade": "Samodzielne ulepszenie do Starter, Growth lub Business", "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.askAdminPlan": "Poproś administratora firmy o zmianę planu.", "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.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.checkoutUnavailable": "Checkout samoobslugowy jest niedostepny, dopoki Stripe nie zostanie skonfigurowany. Skontaktuj sie ze sprzedaza, aby uzyskac plan.", "plans.fallbackName": "Plan taryfowy", "plans.fallbackDescription": "Pojemność dla Twojego katalogu", "plans.badge.current": "Aktualny", diff --git a/apps/web/src/lib/i18n/messages/pt.ts b/apps/web/src/lib/i18n/messages/pt.ts index 54bc694..4563944 100644 --- a/apps/web/src/lib/i18n/messages/pt.ts +++ b/apps/web/src/lib/i18n/messages/pt.ts @@ -802,6 +802,16 @@ export const pt: MessageDict = { "admin.users.colCredits": "Créditos", "admin.users.colLanguage": "Idioma", "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.noCompanies": "Nenhuma empresa corresponde a este filtro.", "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.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.catalogCloned": "Copiado {source} para {dest}: {products} produtos, {categories} categorias.", "flash.admin.planAssigned": "Plano atribuído a {name}.", "flash.admin.planAssignedShort": "Plano atribuído.", "flash.admin.creditsUpdated": "Créditos atualizados.", @@ -3387,6 +3398,7 @@ export const pt: MessageDict = { "billing.needCapacity": "Precisa de mais capacidade?", "billing.selfServeUpgrade": "Atualização self-service para Starter, Growth ou Business", "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.askAdminPlan": "Peça a um administrador da empresa para alterar o plano.", "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.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.checkoutUnavailable": "O checkout self-service nao esta disponivel ate o Stripe ser configurado. Contacte as vendas para um plano.", "plans.fallbackName": "Plano", "plans.fallbackDescription": "Capacidade para o seu catálogo", "plans.badge.current": "Atual", diff --git a/apps/web/src/lib/stripe-billing.ts b/apps/web/src/lib/stripe-billing.ts index 7af72cd..13e84d9 100644 --- a/apps/web/src/lib/stripe-billing.ts +++ b/apps/web/src/lib/stripe-billing.ts @@ -19,6 +19,17 @@ export type StripeStatus = { 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 = { url: string; mock?: boolean; diff --git a/apps/web/src/routes/admin/users/+page.svelte b/apps/web/src/routes/admin/users/+page.svelte index 09e1ddb..ac7808a 100644 --- a/apps/web/src/routes/admin/users/+page.svelte +++ b/apps/web/src/routes/admin/users/+page.svelte @@ -10,6 +10,7 @@ PAGE_SIZE, STAFF_ROLE_OPTIONS, assignAdminPlan, + cloneAdminCompanyCatalog, companyPlanBadge, isStaffRoleApiUnavailable, listAdminCompanies, @@ -24,6 +25,7 @@ type AdminOrgUser, type PlatformStaffRole } from "$lib/admin-orgs"; + import { authSession } from "$lib/auth-session.svelte"; import PageShell from "$lib/components/PageShell.svelte"; import Alert from "$lib/components/Alert.svelte"; import Spinner from "$lib/components/Spinner.svelte"; @@ -52,7 +54,7 @@ TabsList, TabsTrigger } 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"; @@ -88,11 +90,22 @@ let assignCompany = $state(null); let assignPlanId = $state(""); + let cloneOpen = $state(false); + let cloneCompany = $state(null); + const usersPage = $derived(Math.floor(usersOffset / PAGE_SIZE) + 1); const usersPages = $derived(Math.max(1, Math.ceil(usersTotal / PAGE_SIZE))); const companiesPage = $derived(Math.floor(companiesOffset / PAGE_SIZE) + 1); 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 () => { const gate = await requirePlatformAdmin(); 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) { busy = true; error = ""; @@ -712,14 +763,28 @@ - +
+ + +
{/each} @@ -758,8 +823,8 @@
{#if error} @@ -771,7 +836,7 @@

{/if}
- +
- +
@@ -838,3 +905,42 @@ + + +
+ {#if error} + + {/if} + {#if cloneCompany} +

+ {i18n.t("admin.users.cloneCatalogFrom", { name: cloneCompany.name })} +

+ {/if} +

+ {i18n.t("admin.users.cloneCatalogInto", { name: cloneDestLabel })} +

+

+ {i18n.t("admin.users.cloneCatalogWarning")} +

+
+ + +
+
+
diff --git a/apps/web/src/routes/billing/+page.svelte b/apps/web/src/routes/billing/+page.svelte index 121872b..3bfc772 100644 --- a/apps/web/src/routes/billing/+page.svelte +++ b/apps/web/src/routes/billing/+page.svelte @@ -37,6 +37,7 @@ startCheckout, startCreditPackCheckout, redirectToCheckout, + canSelfServePurchase, type StripeStatus } from "$lib/stripe-billing"; import { CREDIT_PACKS } from "$lib/components/pricing/credit-packs"; @@ -255,8 +256,12 @@ ); const atProductLimit = $derived(Boolean(credits?.at_product_limit) && !enterprise); const nextUpgradePlan = $derived(nextSelfServeUpgradePlan(planName)); + const purchasesEnabled = $derived( + canSelfServePurchase(stripe, { isPlatformAdmin }) + ); const showQuickUpgrade = $derived( canManageBilling && + purchasesEnabled && !enterprise && !payg && !isLegacyPlan(plan) && @@ -450,6 +455,10 @@ + {#if canManageBilling && !purchasesEnabled} + + {/if} + {#if recovery} {/if} - {#if canManageBilling && !enterprise && planAssigned} + {#if canManageBilling && purchasesEnabled && !enterprise && planAssigned}

{i18n.t("billing.buyCreditPacks")}

diff --git a/apps/web/src/routes/plans/+page.svelte b/apps/web/src/routes/plans/+page.svelte index 7943c03..a584a09 100644 --- a/apps/web/src/routes/plans/+page.svelte +++ b/apps/web/src/routes/plans/+page.svelte @@ -20,6 +20,7 @@ fetchStripeStatus, redirectToCheckout, startCheckout, + canSelfServePurchase, type StripeStatus } from "$lib/stripe-billing"; import type { CreditBalance, MeResponse } from "$lib/types"; @@ -74,6 +75,7 @@ let apiAvailable = $state(true); let checkoutBusy = $state(null); let canManageBilling = $state(false); + let isPlatformAdmin = $state(false); function apiFor(name: string): ApiPlan | undefined { const key = name.trim().toLowerCase(); @@ -99,6 +101,7 @@ apiPlans = (plansPayload?.plans ?? []).filter((p) => isPublicProductPlan(p.name)); stripe = stripeRes; canManageBilling = isCompanyAdmin(me); + isPlatformAdmin = Boolean(me.user?.is_platform_admin); const bal: CreditBalance | null = me.credits ?? (await api("/api/billing/credits").catch(() => null)); @@ -124,6 +127,7 @@ const currentName = $derived(hasPlan ? planNameOf(currentPlan).toLowerCase() : ""); const remainingLabel = $derived(formatCreditsRemaining(remainingCredits, hasPlan ? currentPlan : null)); const currentIsPayg = $derived(hasPlan && isPayAsYouGoPlan(currentPlan)); + const purchasesEnabled = $derived(canSelfServePurchase(stripe, { isPlatformAdmin })); const displayPlans = $derived.by(() => { return PRICING_PLANS.map((marketing) => { @@ -152,10 +156,14 @@ ctaMode = "link"; ctaHref = "/billing"; } else if (selfServe) { - if (canManageBilling) { + if (canManageBilling && purchasesEnabled) { ctaText = i18n.t("plans.cta.upgradeTo", { name }); ctaMode = "checkout"; ctaHref = "/billing"; + } else if (canManageBilling) { + ctaText = i18n.t("plans.cta.contactSales"); + ctaMode = "link"; + ctaHref = `${CONTACT_SALES_HREF}?source=plans`; } else { ctaText = i18n.t("common.askAdmin"); ctaMode = "link"; @@ -238,6 +246,10 @@ {/if} + {#if canManageBilling && !purchasesEnabled} + + {/if} +

{i18n.t("plans.title")}

@@ -260,7 +272,7 @@ {i18n.t("plans.sub.none")} {/if}

- {#if stripe?.configured && !stripe?.mock} + {#if purchasesEnabled && stripe?.configured && !stripe?.mock}

{i18n.t("plans.stripeHint")}

{/if}