fixes
This commit is contained in:
@@ -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),
|
||||||
|
|||||||
@@ -100,11 +100,31 @@ func (s *StripeService) cfg(ctx context.Context) StripeConfig {
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
ErrStripeNotConfigured = errors.New("stripe not configured")
|
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")
|
ErrStripePlanUnsupported = errors.New("plan is not available for self-serve checkout")
|
||||||
ErrStripePriceMissing = errors.New("stripe price id not configured for plan/term")
|
ErrStripePriceMissing = errors.New("stripe price id not configured for plan/term")
|
||||||
ErrStripeBadSignature = errors.New("invalid stripe signature")
|
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 {
|
||||||
|
|||||||
@@ -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"})
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -390,6 +390,7 @@ func (s *Server) Router() http.Handler {
|
|||||||
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()
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -745,6 +756,11 @@
|
|||||||
{#each categories as cat}
|
{#each categories as cat}
|
||||||
<option value={cat.uniqueId}>{cat.name}</option>
|
<option value={cat.uniqueId}>{cat.name}</option>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if category !== "none" && !categories.some((c) => c.uniqueId === category)}
|
||||||
|
<option value={category}
|
||||||
|
>{categoryFieldDisplayValue(categories, product, category)}</option
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -866,6 +882,11 @@
|
|||||||
{#each categories as cat}
|
{#each categories as cat}
|
||||||
<option value={cat.uniqueId}>{cat.name}</option>
|
<option value={cat.uniqueId}>{cat.name}</option>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if category !== "none" && !categories.some((c) => c.uniqueId === category)}
|
||||||
|
<option value={category}
|
||||||
|
>{categoryFieldDisplayValue(categories, product, category)}</option
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 A–Z). */
|
/** 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);
|
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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "現在",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,6 +763,19 @@
|
|||||||
</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>
|
||||||
|
<div class="flex flex-wrap items-center justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onclick={() => openCloneDialog(company)}
|
||||||
|
aria-label={i18n.t("admin.users.cloneCatalogAria", {
|
||||||
|
name: company.name
|
||||||
|
})}
|
||||||
|
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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onclick={() => openAssignDialog(company)}
|
onclick={() => openAssignDialog(company)}
|
||||||
@@ -720,6 +784,7 @@
|
|||||||
<UserPlus class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
|
<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>
|
<span class="hidden lg:inline">{i18n.t("admin.users.assignPlan")}</span>
|
||||||
</Button>
|
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user