-
-
-
+
+ {#each categories as cat}
+
+ {/each}
+ {#if category !== "none" && !categories.some((c) => c.uniqueId === category)}
+
+ {/if}
+
+
+
+
+
@@ -1115,7 +1136,7 @@
{/if}
{attr.value}{attrDisplayValue(attr.key, attr.value)}
{/each}
@@ -1133,7 +1154,9 @@
class="flex items-start justify-between gap-3 rounded-md border border-border px-3 py-2"
>
{attr.key}
- {attr.value}
+ {attrDisplayValue(attr.key, attr.value)}
{/each}
@@ -1150,7 +1173,9 @@
class="flex items-start justify-between gap-3 rounded-md border border-border px-3 py-2"
>
{attr.key}
- {attr.value}
+ {attrDisplayValue(attr.key, attr.value)}
{/each}
diff --git a/apps/web/src/lib/components/products/types.ts b/apps/web/src/lib/components/products/types.ts
index 12e3af9..0b9b58d 100644
--- a/apps/web/src/lib/components/products/types.ts
+++ b/apps/web/src/lib/components/products/types.ts
@@ -126,7 +126,15 @@ export function categoryDisplayName(
): string {
if (p && typeof p === "object") {
const fromApi = String(p.category_name ?? "").trim();
- if (fromApi) return fromApi;
+ if (fromApi) {
+ // API falls back to the raw id when the JOIN misses; prefer catalog name when available.
+ const resolved =
+ findCategoryOption(options, p.category_unique_id)?.name ??
+ findCategoryOption(options, p.category)?.name ??
+ findCategoryOption(options, fromApi)?.name;
+ if (resolved) return resolved;
+ return fromApi;
+ }
const ref = String(p.category_unique_id ?? p.category ?? "").trim();
if (!ref || ref.toLowerCase() === "none") return i18n.t("products.table.uncategorized");
return (
@@ -140,6 +148,34 @@ export function categoryDisplayName(
return findCategoryOption(options, ref)?.name ?? ref;
}
+/**
+ * Resolve a feed/attr category value (often a numeric unique_id) to a display name.
+ * Prefer catalog options, then product.category_name when the raw value matches the product category.
+ */
+export function categoryFieldDisplayValue(
+ options: CategoryOption[],
+ product: Pick | null | undefined,
+ rawValue: string
+): string {
+ const raw = String(rawValue ?? "").trim();
+ if (!raw || raw === "—" || raw.toLowerCase() === "none") return raw;
+ const fromOptions = findCategoryOption(options, raw)?.name;
+ if (fromOptions) return fromOptions;
+ if (product) {
+ const productRef = String(product.category_unique_id ?? product.category ?? "").trim();
+ const productName = String(product.category_name ?? "").trim();
+ if (productName && (raw === productRef || raw === String(product.category ?? "").trim())) {
+ const better =
+ findCategoryOption(options, product.category_unique_id)?.name ??
+ findCategoryOption(options, product.category)?.name ??
+ findCategoryOption(options, productName)?.name;
+ if (better) return better;
+ if (productName !== raw) return productName;
+ }
+ }
+ return raw;
+}
+
export function productDisplayName(p: ProductRow, kind: "processed" | "raw"): string {
const fallback = i18n.t("products.unnamed");
if (kind === "processed") {
@@ -493,8 +529,11 @@ export function resolveOriginalName(product: ProductRow | null | undefined): str
}
/** Compact feed-field preview for the product panel (preferred mapped_data keys). */
-export function productFeedFieldSummary(product: ProductRow | null | undefined): { key: string; value: string; ok: boolean }[] {
- return productFeedMappedEntries(product).filter((e) => e.preferred);
+export function productFeedFieldSummary(
+ product: ProductRow | null | undefined,
+ options: CategoryOption[] = []
+): { key: string; value: string; ok: boolean }[] {
+ return productFeedMappedEntries(product, options).filter((e) => e.preferred);
}
/** Format any mapped_data value for display (scalars + nested JSON). */
@@ -523,7 +562,10 @@ export type FeedMappedEntry = {
};
/** Full mapped_data inventory for the Feed tab (preferred keys first, then A–Z). */
-export function productFeedMappedEntries(product: ProductRow | null | undefined): FeedMappedEntry[] {
+export function productFeedMappedEntries(
+ product: ProductRow | null | undefined,
+ options: CategoryOption[] = []
+): FeedMappedEntry[] {
const mapped = asRecord(product?.mapped_data);
if (!mapped) return [];
const preferred = [
@@ -548,7 +590,10 @@ export function productFeedMappedEntries(product: ProductRow | null | undefined)
return a.localeCompare(b);
});
return keys.map((key) => {
- const formatted = formatMappedFeedValue(mapped[key]);
+ let formatted = formatMappedFeedValue(mapped[key]);
+ if (key.toLowerCase() === "category" && formatted.trim()) {
+ formatted = categoryFieldDisplayValue(options, product, formatted);
+ }
const multiline = formatted.includes("\n") || formatted.length > 120;
return {
key,
diff --git a/apps/web/src/lib/i18n/messages/de.ts b/apps/web/src/lib/i18n/messages/de.ts
index 6cad7b5..d31b50f 100644
--- a/apps/web/src/lib/i18n/messages/de.ts
+++ b/apps/web/src/lib/i18n/messages/de.ts
@@ -802,6 +802,16 @@ export const de: MessageDict = {
"admin.users.colCredits": "Credits",
"admin.users.colLanguage": "Sprache",
"admin.users.assignPlan": "Plan zuweisen",
+ "admin.users.cloneCatalog": "In mein Unternehmen kopieren",
+ "admin.users.cloneCatalogAria": "Katalog von {name} in mein Sandbox-Unternehmen kopieren",
+ "admin.users.cloneCatalogTitle": "Unternehmenskatalog kopieren",
+ "admin.users.cloneCatalogDesc": "Sie bleiben auf Ihrem Admin-Konto. Quelldaten bleiben unverändert.",
+ "admin.users.cloneCatalogFrom": "Quelle: {name}",
+ "admin.users.cloneCatalogInto": "Ziel: {name}",
+ "admin.users.cloneCatalogWarning": "Ersetzt Kategorien, Feeds, Produkte, Attribute, Mappings und Branding im Ziel. Plan, Abrechnung, Mitglieder, API-Schlüssel und Store-Connectoren werden nicht kopiert.",
+ "admin.users.cloneCatalogCancel": "Abbrechen",
+ "admin.users.cloneCatalogConfirm": "Katalog kopieren",
+ "admin.users.cloneDestFallback": "Ihr Sandbox-Unternehmen",
"admin.users.noUsers": "Keine Benutzer entsprechen diesem Filter.",
"admin.users.noCompanies": "Keine Unternehmen entsprechen diesem Filter.",
"admin.users.assignRoleTitle": "Mitarbeiterrolle zuweisen",
@@ -2286,6 +2296,7 @@ export const de: MessageDict = {
"flash.admin.staffRoleUpdated": "Mitarbeiterrolle für {email} aktualisiert.",
"flash.admin.staffRoleUnavailable": "Mitarbeiterrollen-Updates sind auf diesem Server noch nicht verfügbar.",
"flash.admin.planAssigned": "Plan {name} zugewiesen.",
+ "flash.admin.catalogCloned": "{source} nach {dest} kopiert: {products} Produkte, {categories} Kategorien.",
"flash.admin.planAssignedShort": "Plan zugewiesen.",
"flash.admin.creditsUpdated": "Credits aktualisiert.",
"flash.admin.cyclesProcessed": "Abrechnungszyklen verarbeitet: {count}",
@@ -3387,6 +3398,7 @@ export const de: MessageDict = {
"billing.needCapacity": "Mehr Kapazität nötig?",
"billing.selfServeUpgrade": "Self-Service-Upgrade auf Starter, Growth oder Business",
"billing.viaStripe": "über Stripe",
+ "billing.checkoutUnavailable": "Selbstbedienungskäufe sind nicht verfügbar, bis Stripe eingerichtet ist. Kontaktieren Sie den Vertrieb für einen Plan.",
"billing.upgradeToGrowth": "Auf Growth upgraden",
"billing.askAdminPlan": "Bitten Sie einen Firmen-Admin, den Plan zu ändern.",
"billing.usage": "Nutzung",
@@ -4821,6 +4833,7 @@ export const de: MessageDict = {
"plans.sub.creditsSuffix": "Upgrade Starter → Growth → Business über Checkout, oder sprechen Sie mit dem Vertrieb über Enterprise.",
"plans.sub.none": "Noch kein Plan zugewiesen (z. B. nach einer übersprungenen Migration). Wählen Sie unten einen Plan — die Kapazität ist nicht Unbegrenzt, bis Checkout oder ein Admin einen zuweist.",
"plans.stripeHint": "Self-Service-Upgrades nutzen Stripe Checkout.",
+ "plans.checkoutUnavailable": "Self-Service-Checkout ist nicht verfügbar, bis Stripe eingerichtet ist. Kontaktieren Sie den Vertrieb für einen Plan.",
"plans.fallbackName": "Tarifplan",
"plans.fallbackDescription": "Kapazität für Ihren Katalog",
"plans.badge.current": "Aktuell",
diff --git a/apps/web/src/lib/i18n/messages/en.ts b/apps/web/src/lib/i18n/messages/en.ts
index d1dfa80..03e95d2 100644
--- a/apps/web/src/lib/i18n/messages/en.ts
+++ b/apps/web/src/lib/i18n/messages/en.ts
@@ -814,13 +814,27 @@ export const en: MessageDict = {
"admin.users.mustSetPassword": "Must set password",
"admin.users.inactive": "Inactive",
"admin.users.reissueInvite": "Re-issue invite",
- "admin.users.setLocalPassword": "Set local password",
+ "admin.users.setLocalPassword": "Set password",
+ "admin.users.setPasswordTitle": "Set password",
+ "admin.users.setPasswordDesc": "Force-set a login password for this user (works for legacy or fake emails that cannot receive invites).",
+ "admin.users.newPassword": "New password",
+ "admin.users.setPasswordSubmit": "Set password",
"admin.users.switchToUser": "Switch to user",
"admin.users.colCompany": "Company",
"admin.users.colPlan": "Plan",
"admin.users.colCredits": "Credits",
"admin.users.colLanguage": "Language",
"admin.users.assignPlan": "Assign plan",
+ "admin.users.cloneCatalog": "Copy to my company",
+ "admin.users.cloneCatalogAria": "Copy {name} catalog into your sandbox company",
+ "admin.users.cloneCatalogTitle": "Copy company catalog",
+ "admin.users.cloneCatalogDesc": "Stay on your admin account. Source data is not changed.",
+ "admin.users.cloneCatalogFrom": "Source: {name}",
+ "admin.users.cloneCatalogInto": "Destination: {name}",
+ "admin.users.cloneCatalogWarning": "Replaces categories, feeds, products, attributes, mappings, and brand on the destination. Plan, billing, members, API keys, and store connectors are not copied.",
+ "admin.users.cloneCatalogCancel": "Cancel",
+ "admin.users.cloneCatalogConfirm": "Copy catalog",
+ "admin.users.cloneDestFallback": "your sandbox company",
"admin.users.noUsers": "No users match this filter.",
"admin.users.noCompanies": "No companies match this filter.",
"admin.users.assignRoleTitle": "Assign staff role",
@@ -2307,14 +2321,15 @@ export const en: MessageDict = {
"flash.admin.staffRoleUpdated": "Staff role updated for {email}.",
"flash.admin.staffRoleUnavailable": "Staff role updates are not available on this server yet.",
"flash.admin.planAssigned": "Plan assigned to {name}.",
+ "flash.admin.catalogCloned": "Copied {source} into {dest}: {products} products, {categories} categories.",
"flash.admin.planAssignedShort": "Plan assigned.",
"flash.admin.creditsUpdated": "Credits updated.",
"flash.admin.cyclesProcessed": "Billing cycles processed: {count}",
"flash.admin.invitesSummary": "Set-password invites: {parts}. Email delivery is {smtp}.",
"flash.admin.smtpOn": "on",
"flash.admin.smtpOff": "off",
- "flash.admin.localPasswordSet": "Local password set for {email}.",
- "flash.admin.localPasswordUnavailable": "Local password tools are not available in this environment.",
+ "flash.admin.localPasswordSet": "Password set for {email}.",
+ "flash.admin.localPasswordUnavailable": "Set-password tools are not available in this environment.",
"flash.admin.switchedUser": "Switched user. Reloading…",
"flash.admin.switchUnavailable": "User switch is not available in this environment.",
"flash.admin.diagnosticsRateLimit": "Diagnostics rate limit reached (20/min). Wait a minute and refresh.",
@@ -3410,6 +3425,7 @@ export const en: MessageDict = {
"billing.needCapacity": "Need more capacity?",
"billing.selfServeUpgrade": "Self-serve upgrade to Starter, Growth, or Business",
"billing.viaStripe": "via Stripe",
+ "billing.checkoutUnavailable": "Self-serve purchases are unavailable until Stripe is configured. Contact sales for a plan.",
"billing.upgradeToGrowth": "Upgrade to Growth",
"billing.askAdminPlan": "Ask a company admin to change the plan.",
"billing.usage": "Usage",
@@ -4844,6 +4860,7 @@ export const en: MessageDict = {
"plans.sub.creditsSuffix": "Upgrade Starter → Growth → Business with Checkout, or talk to sales for Enterprise.",
"plans.sub.none": "No plan is assigned yet (for example after a skipped migration). Choose a plan below — capacity is not Unlimited until Checkout or an admin assigns one.",
"plans.stripeHint": "Self-serve upgrades use Stripe Checkout.",
+ "plans.checkoutUnavailable": "Self-serve checkout is unavailable until Stripe is configured. Contact sales to get a plan.",
"plans.fallbackName": "Plan",
"plans.fallbackDescription": "Capacity for your catalog",
"plans.badge.current": "Current",
diff --git a/apps/web/src/lib/i18n/messages/es.ts b/apps/web/src/lib/i18n/messages/es.ts
index ee806d7..838bd7a 100644
--- a/apps/web/src/lib/i18n/messages/es.ts
+++ b/apps/web/src/lib/i18n/messages/es.ts
@@ -802,6 +802,16 @@ export const es: MessageDict = {
"admin.users.colCredits": "Créditos",
"admin.users.colLanguage": "Idioma",
"admin.users.assignPlan": "Asignar plan",
+ "admin.users.cloneCatalog": "Copiar a mi empresa",
+ "admin.users.cloneCatalogAria": "Copiar el catálogo de {name} a tu empresa sandbox",
+ "admin.users.cloneCatalogTitle": "Copiar catálogo de empresa",
+ "admin.users.cloneCatalogDesc": "Sigues en tu cuenta de admin. Los datos de origen no cambian.",
+ "admin.users.cloneCatalogFrom": "Origen: {name}",
+ "admin.users.cloneCatalogInto": "Destino: {name}",
+ "admin.users.cloneCatalogWarning": "Sustituye categorías, feeds, productos, atributos, mappings y marca en el destino. Plan, facturación, miembros, claves API y conectores de tienda no se copian.",
+ "admin.users.cloneCatalogCancel": "Cancelar",
+ "admin.users.cloneCatalogConfirm": "Copiar catálogo",
+ "admin.users.cloneDestFallback": "tu empresa sandbox",
"admin.users.noUsers": "Ningún usuario coincide con este filtro.",
"admin.users.noCompanies": "Ninguna empresa coincide con este filtro.",
"admin.users.assignRoleTitle": "Asignar rol de personal",
@@ -2285,6 +2295,7 @@ export const es: MessageDict = {
"flash.processing.retried": "\"{name}\" en cola para reintento.",
"flash.admin.staffRoleUpdated": "Rol de personal actualizado para {email}.",
"flash.admin.staffRoleUnavailable": "Las actualizaciones de rol de personal aún no están disponibles en este servidor.",
+ "flash.admin.catalogCloned": "Se copió {source} en {dest}: {products} productos, {categories} categorías.",
"flash.admin.planAssigned": "Plan asignado a {name}.",
"flash.admin.planAssignedShort": "Plan asignado.",
"flash.admin.creditsUpdated": "Créditos actualizados.",
@@ -3387,6 +3398,7 @@ export const es: MessageDict = {
"billing.needCapacity": "¿Necesitas más capacidad?",
"billing.selfServeUpgrade": "Mejora autoservicio a Starter, Growth o Business",
"billing.viaStripe": "vía Stripe",
+ "billing.checkoutUnavailable": "Las compras de autoservicio no estan disponibles hasta que Stripe este configurado. Contacta con ventas para un plan.",
"billing.upgradeToGrowth": "Mejorar a Growth",
"billing.askAdminPlan": "Pide a un administrador de la empresa que cambie el plan.",
"billing.usage": "Uso",
@@ -4821,6 +4833,7 @@ export const es: MessageDict = {
"plans.sub.creditsSuffix": "Mejora Starter → Growth → Business con Checkout, o habla con ventas para Enterprise.",
"plans.sub.none": "Aún no hay un plan asignado (por ejemplo tras una migración omitida). Elige un plan abajo — la capacidad no es Ilimitada hasta que Checkout o un administrador asigne uno.",
"plans.stripeHint": "Los upgrades de autoservicio usan Stripe Checkout.",
+ "plans.checkoutUnavailable": "El checkout de autoservicio no esta disponible hasta que Stripe este configurado. Contacta con ventas para un plan.",
"plans.fallbackName": "Plan tarifario",
"plans.fallbackDescription": "Capacidad para tu catálogo",
"plans.badge.current": "Actual",
diff --git a/apps/web/src/lib/i18n/messages/fr.ts b/apps/web/src/lib/i18n/messages/fr.ts
index 9e777d2..56cad42 100644
--- a/apps/web/src/lib/i18n/messages/fr.ts
+++ b/apps/web/src/lib/i18n/messages/fr.ts
@@ -801,7 +801,17 @@ export const fr: MessageDict = {
"admin.users.colPlan": "Offre",
"admin.users.colCredits": "Crédits",
"admin.users.colLanguage": "Langue",
- "admin.users.assignPlan": "Assigner une offre",
+ "admin.users.assignPlan": "Attribuer un forfait",
+ "admin.users.cloneCatalog": "Copier vers mon entreprise",
+ "admin.users.cloneCatalogAria": "Copier le catalogue de {name} vers votre entreprise sandbox",
+ "admin.users.cloneCatalogTitle": "Copier le catalogue d'entreprise",
+ "admin.users.cloneCatalogDesc": "Vous restez sur votre compte admin. Les données source ne sont pas modifiées.",
+ "admin.users.cloneCatalogFrom": "Source : {name}",
+ "admin.users.cloneCatalogInto": "Destination : {name}",
+ "admin.users.cloneCatalogWarning": "Remplace catégories, feeds, produits, attributs, mappings et marque sur la destination. Forfait, facturation, membres, clés API et connecteurs boutique ne sont pas copiés.",
+ "admin.users.cloneCatalogCancel": "Annuler",
+ "admin.users.cloneCatalogConfirm": "Copier le catalogue",
+ "admin.users.cloneDestFallback": "votre entreprise sandbox",
"admin.users.noUsers": "Aucun utilisateur ne correspond à ce filtre.",
"admin.users.noCompanies": "Aucune entreprise ne correspond à ce filtre.",
"admin.users.assignRoleTitle": "Assigner un rôle du personnel",
@@ -2285,6 +2295,7 @@ export const fr: MessageDict = {
"flash.processing.retried": "« {name} » mis en file pour nouvel essai.",
"flash.admin.staffRoleUpdated": "Rôle du personnel mis à jour pour {email}.",
"flash.admin.staffRoleUnavailable": "Les mises à jour de rôle personnel ne sont pas encore disponibles sur ce serveur.",
+ "flash.admin.catalogCloned": "{source} copié vers {dest} : {products} produits, {categories} catégories.",
"flash.admin.planAssigned": "Offre assignée à {name}.",
"flash.admin.planAssignedShort": "Offre assignée.",
"flash.admin.creditsUpdated": "Crédits mis à jour.",
@@ -3387,6 +3398,7 @@ export const fr: MessageDict = {
"billing.needCapacity": "Besoin de plus de capacité ?",
"billing.selfServeUpgrade": "Mise à niveau en libre-service vers Starter, Growth ou Business",
"billing.viaStripe": "via Stripe",
+ "billing.checkoutUnavailable": "Les achats en libre-service sont indisponibles tant que Stripe n'est pas configuré. Contactez les ventes pour un forfait.",
"billing.upgradeToGrowth": "Passer à Growth",
"billing.askAdminPlan": "Demandez à un administrateur de changer le plan.",
"billing.usage": "Utilisation",
@@ -4821,6 +4833,7 @@ export const fr: MessageDict = {
"plans.sub.creditsSuffix": "Passez de Starter → Growth → Business via Checkout, ou contactez les ventes pour Enterprise.",
"plans.sub.none": "Aucun plan n'est encore attribué (par exemple après une migration ignorée). Choisissez un plan ci-dessous — la capacité n'est pas Illimitée tant que Checkout ou un administrateur n'en a pas attribué un.",
"plans.stripeHint": "Les mises à niveau en libre-service utilisent Stripe Checkout.",
+ "plans.checkoutUnavailable": "Le paiement en libre-service est indisponible tant que Stripe n'est pas configuré. Contactez les ventes pour un forfait.",
"plans.fallbackName": "Offre",
"plans.fallbackDescription": "Capacité pour votre catalogue",
"plans.badge.current": "Actuel",
diff --git a/apps/web/src/lib/i18n/messages/it.ts b/apps/web/src/lib/i18n/messages/it.ts
index 7ff7b50..acb23b5 100644
--- a/apps/web/src/lib/i18n/messages/it.ts
+++ b/apps/web/src/lib/i18n/messages/it.ts
@@ -802,6 +802,16 @@ export const it: MessageDict = {
"admin.users.colCredits": "Crediti",
"admin.users.colLanguage": "Lingua",
"admin.users.assignPlan": "Assegna piano",
+ "admin.users.cloneCatalog": "Copia nella mia azienda",
+ "admin.users.cloneCatalogAria": "Copia il catalogo di {name} nella tua azienda sandbox",
+ "admin.users.cloneCatalogTitle": "Copia catalogo azienda",
+ "admin.users.cloneCatalogDesc": "Resti sul tuo account admin. I dati di origine non vengono modificati.",
+ "admin.users.cloneCatalogFrom": "Origine: {name}",
+ "admin.users.cloneCatalogInto": "Destinazione: {name}",
+ "admin.users.cloneCatalogWarning": "Sostituisce categorie, feed, prodotti, attributi, mapping e brand sulla destinazione. Piano, fatturazione, membri, chiavi API e connettori store non vengono copiati.",
+ "admin.users.cloneCatalogCancel": "Annulla",
+ "admin.users.cloneCatalogConfirm": "Copia catalogo",
+ "admin.users.cloneDestFallback": "la tua azienda sandbox",
"admin.users.noUsers": "Nessun utente corrisponde a questo filtro.",
"admin.users.noCompanies": "Nessuna azienda corrisponde a questo filtro.",
"admin.users.assignRoleTitle": "Assegna ruolo staff",
@@ -2285,6 +2295,7 @@ export const it: MessageDict = {
"flash.processing.retried": "\"{name}\" messo in coda per riprovare.",
"flash.admin.staffRoleUpdated": "Ruolo staff aggiornato per {email}.",
"flash.admin.staffRoleUnavailable": "Gli aggiornamenti del ruolo staff non sono ancora disponibili su questo server.",
+ "flash.admin.catalogCloned": "Copiato {source} in {dest}: {products} prodotti, {categories} categorie.",
"flash.admin.planAssigned": "Piano assegnato a {name}.",
"flash.admin.planAssignedShort": "Piano assegnato.",
"flash.admin.creditsUpdated": "Crediti aggiornati.",
@@ -3387,6 +3398,7 @@ export const it: MessageDict = {
"billing.needCapacity": "Serve più capacità?",
"billing.selfServeUpgrade": "Upgrade self-service a Starter, Growth o Business",
"billing.viaStripe": "tramite Stripe",
+ "billing.checkoutUnavailable": "Gli acquisti self-service non sono disponibili finche Stripe non e configurato. Contatta le vendite per un piano.",
"billing.upgradeToGrowth": "Passa a Growth",
"billing.askAdminPlan": "Chiedi a un amministratore di cambiare il piano.",
"billing.usage": "Utilizzo",
@@ -4821,6 +4833,7 @@ export const it: MessageDict = {
"plans.sub.creditsSuffix": "Passa da Starter → Growth → Business con Checkout, oppure parla con le vendite per Enterprise.",
"plans.sub.none": "Nessun piano è ancora assegnato (ad esempio dopo una migrazione saltata). Scegli un piano qui sotto — la capacità non è Illimitata finché Checkout o un amministratore non ne assegna uno.",
"plans.stripeHint": "Gli upgrade self-service usano Stripe Checkout.",
+ "plans.checkoutUnavailable": "Il checkout self-service non e disponibile finche Stripe non e configurato. Contatta le vendite per un piano.",
"plans.fallbackName": "Piano",
"plans.fallbackDescription": "Capacità per il tuo catalogo",
"plans.badge.current": "Attuale",
diff --git a/apps/web/src/lib/i18n/messages/ja.ts b/apps/web/src/lib/i18n/messages/ja.ts
index dac949c..7bdc554 100644
--- a/apps/web/src/lib/i18n/messages/ja.ts
+++ b/apps/web/src/lib/i18n/messages/ja.ts
@@ -802,6 +802,16 @@ export const ja: MessageDict = {
"admin.users.colCredits": "クレジット",
"admin.users.colLanguage": "言語",
"admin.users.assignPlan": "プランを割り当て",
+ "admin.users.cloneCatalog": "自分の会社にコピー",
+ "admin.users.cloneCatalogAria": "{name} のカタログをサンドボックス会社へコピー",
+ "admin.users.cloneCatalogTitle": "会社カタログをコピー",
+ "admin.users.cloneCatalogDesc": "管理者アカウントのまま操作します。元データは変更されません。",
+ "admin.users.cloneCatalogFrom": "コピー元: {name}",
+ "admin.users.cloneCatalogInto": "コピー先: {name}",
+ "admin.users.cloneCatalogWarning": "コピー先のカテゴリ、フィード、商品、属性、マッピング、ブランドを置き換えます。プラン、請求、メンバー、APIキー、ストア連携はコピーされません。",
+ "admin.users.cloneCatalogCancel": "キャンセル",
+ "admin.users.cloneCatalogConfirm": "カタログをコピー",
+ "admin.users.cloneDestFallback": "サンドボックス会社",
"admin.users.noUsers": "このフィルタに一致するユーザーはいません。",
"admin.users.noCompanies": "このフィルタに一致する会社はありません。",
"admin.users.assignRoleTitle": "スタッフロールを割り当て",
@@ -2285,6 +2295,7 @@ export const ja: MessageDict = {
"flash.processing.retried": "「{name}」を再試行キューに入れました。",
"flash.admin.staffRoleUpdated": "{email} のスタッフロールを更新しました。",
"flash.admin.staffRoleUnavailable": "このサーバーではスタッフロールの更新はまだ利用できません。",
+ "flash.admin.catalogCloned": "{source} を {dest} にコピーしました: 商品 {products}、カテゴリ {categories}。",
"flash.admin.planAssigned": "{name} にプランを割り当てました。",
"flash.admin.planAssignedShort": "プランを割り当てました。",
"flash.admin.creditsUpdated": "クレジットを更新しました。",
@@ -3387,6 +3398,7 @@ export const ja: MessageDict = {
"billing.needCapacity": "容量が足りませんか?",
"billing.selfServeUpgrade": "Starter、Growth、または Business へのセルフサービスアップグレード",
"billing.viaStripe": "Stripe経由",
+ "billing.checkoutUnavailable": "Stripe の設定が完了するまでセルフサービス購入は利用できません。プランについては営業にお問い合わせください。",
"billing.upgradeToGrowth": "Growthにアップグレード",
"billing.askAdminPlan": "会社管理者にプラン変更を依頼してください。",
"billing.usage": "利用状況",
@@ -4821,6 +4833,7 @@ export const ja: MessageDict = {
"plans.sub.creditsSuffix": "Checkout で Starter → Growth → Business にアップグレードするか、Enterprise については営業にお問い合わせください。",
"plans.sub.none": "まだプランが割り当てられていません(例:スキップされた移行後)。下からプランを選択してください — Checkout または管理者が割り当てるまで、容量は無制限ではありません。",
"plans.stripeHint": "セルフサービスのアップグレードは Stripe Checkout を使用します。",
+ "plans.checkoutUnavailable": "Stripe の設定が完了するまでセルフサービス Checkout は利用できません。プランについては営業にお問い合わせください。",
"plans.fallbackName": "プラン",
"plans.fallbackDescription": "カタログ向けの容量",
"plans.badge.current": "現在",
diff --git a/apps/web/src/lib/i18n/messages/nl.ts b/apps/web/src/lib/i18n/messages/nl.ts
index 5430448..f16cef0 100644
--- a/apps/web/src/lib/i18n/messages/nl.ts
+++ b/apps/web/src/lib/i18n/messages/nl.ts
@@ -802,6 +802,16 @@ export const nl: MessageDict = {
"admin.users.colCredits": "Credits",
"admin.users.colLanguage": "Taal",
"admin.users.assignPlan": "Plan toewijzen",
+ "admin.users.cloneCatalog": "Kopiëren naar mijn bedrijf",
+ "admin.users.cloneCatalogAria": "Catalogus van {name} naar je sandboxbedrijf kopiëren",
+ "admin.users.cloneCatalogTitle": "Bedrijfscatalogus kopiëren",
+ "admin.users.cloneCatalogDesc": "Je blijft op je admin-account. Brongegevens worden niet gewijzigd.",
+ "admin.users.cloneCatalogFrom": "Bron: {name}",
+ "admin.users.cloneCatalogInto": "Bestemming: {name}",
+ "admin.users.cloneCatalogWarning": "Vervangt categorieën, feeds, producten, attributen, mappings en merk op de bestemming. Plan, facturatie, leden, API-sleutels en store-connectors worden niet gekopieerd.",
+ "admin.users.cloneCatalogCancel": "Annuleren",
+ "admin.users.cloneCatalogConfirm": "Catalogus kopiëren",
+ "admin.users.cloneDestFallback": "je sandboxbedrijf",
"admin.users.noUsers": "Geen gebruikers komen overeen met dit filter.",
"admin.users.noCompanies": "Geen bedrijven komen overeen met dit filter.",
"admin.users.assignRoleTitle": "Medewerkerrol toewijzen",
@@ -2285,6 +2295,7 @@ export const nl: MessageDict = {
"flash.processing.retried": "\"{name}\" in de wachtrij voor opnieuw proberen.",
"flash.admin.staffRoleUpdated": "Personeelsrol bijgewerkt voor {email}.",
"flash.admin.staffRoleUnavailable": "Personeelsrolupdates zijn op deze server nog niet beschikbaar.",
+ "flash.admin.catalogCloned": "{source} gekopieerd naar {dest}: {products} producten, {categories} categorieën.",
"flash.admin.planAssigned": "Plan toegewezen aan {name}.",
"flash.admin.planAssignedShort": "Plan toegewezen.",
"flash.admin.creditsUpdated": "Credits bijgewerkt.",
@@ -3387,6 +3398,7 @@ export const nl: MessageDict = {
"billing.needCapacity": "Meer capaciteit nodig?",
"billing.selfServeUpgrade": "Self-service-upgrade naar Starter, Growth of Business",
"billing.viaStripe": "via Stripe",
+ "billing.checkoutUnavailable": "Self-serviceaankopen zijn niet beschikbaar totdat Stripe is geconfigureerd. Neem contact op met sales voor een abonnement.",
"billing.upgradeToGrowth": "Upgraden naar Growth",
"billing.askAdminPlan": "Vraag een bedrijfsbeheerder om het plan te wijzigen.",
"billing.usage": "Gebruik",
@@ -4821,6 +4833,7 @@ export const nl: MessageDict = {
"plans.sub.creditsSuffix": "Upgrade Starter → Growth → Business via Checkout, of neem contact op met sales voor Enterprise.",
"plans.sub.none": "Er is nog geen abonnement toegewezen (bijvoorbeeld na een overgeslagen migratie). Kies hieronder een abonnement — de capaciteit is niet Onbeperkt totdat Checkout of een beheerder er een toewijst.",
"plans.stripeHint": "Zelfbedieningsupgrades gebruiken Stripe Checkout.",
+ "plans.checkoutUnavailable": "Self-service-checkout is niet beschikbaar totdat Stripe is geconfigureerd. Neem contact op met sales voor een abonnement.",
"plans.fallbackName": "Abonnement",
"plans.fallbackDescription": "Capaciteit voor uw catalogus",
"plans.badge.current": "Huidig",
diff --git a/apps/web/src/lib/i18n/messages/pl.ts b/apps/web/src/lib/i18n/messages/pl.ts
index 56e8638..a5987df 100644
--- a/apps/web/src/lib/i18n/messages/pl.ts
+++ b/apps/web/src/lib/i18n/messages/pl.ts
@@ -802,6 +802,16 @@ export const pl: MessageDict = {
"admin.users.colCredits": "Kredyty",
"admin.users.colLanguage": "Język",
"admin.users.assignPlan": "Przypisz plan",
+ "admin.users.cloneCatalog": "Kopiuj do mojej firmy",
+ "admin.users.cloneCatalogAria": "Skopiuj katalog {name} do firmy sandbox",
+ "admin.users.cloneCatalogTitle": "Kopiuj katalog firmy",
+ "admin.users.cloneCatalogDesc": "Pozostajesz na koncie admina. Dane źródłowe nie są zmieniane.",
+ "admin.users.cloneCatalogFrom": "Źródło: {name}",
+ "admin.users.cloneCatalogInto": "Cel: {name}",
+ "admin.users.cloneCatalogWarning": "Zastępuje kategorie, feedy, produkty, atrybuty, mapowania i branding w celu. Plan, rozliczenia, członkowie, klucze API i konektory sklepu nie są kopiowane.",
+ "admin.users.cloneCatalogCancel": "Anuluj",
+ "admin.users.cloneCatalogConfirm": "Kopiuj katalog",
+ "admin.users.cloneDestFallback": "twoja firma sandbox",
"admin.users.noUsers": "Żaden użytkownik nie pasuje do tego filtra.",
"admin.users.noCompanies": "Żadna firma nie pasuje do tego filtra.",
"admin.users.assignRoleTitle": "Przypisz rolę personelu",
@@ -2285,6 +2295,7 @@ export const pl: MessageDict = {
"flash.processing.retried": "\"{name}\" w kolejce do ponowienia.",
"flash.admin.staffRoleUpdated": "Zaktualizowano rolę personelu dla {email}.",
"flash.admin.staffRoleUnavailable": "Aktualizacje ról personelu nie są jeszcze dostępne na tym serwerze.",
+ "flash.admin.catalogCloned": "Skopiowano {source} do {dest}: {products} produktów, {categories} kategorii.",
"flash.admin.planAssigned": "Przypisano plan do {name}.",
"flash.admin.planAssignedShort": "Przypisano plan.",
"flash.admin.creditsUpdated": "Zaktualizowano kredyty.",
@@ -3387,6 +3398,7 @@ export const pl: MessageDict = {
"billing.needCapacity": "Potrzebujesz większej pojemności?",
"billing.selfServeUpgrade": "Samodzielne ulepszenie do Starter, Growth lub Business",
"billing.viaStripe": "przez Stripe",
+ "billing.checkoutUnavailable": "Zakupy samoobslugowe sa niedostepne, dopoki Stripe nie zostanie skonfigurowany. Skontaktuj sie ze sprzedaza, aby uzyskac plan.",
"billing.upgradeToGrowth": "Ulepsz do Growth",
"billing.askAdminPlan": "Poproś administratora firmy o zmianę planu.",
"billing.usage": "Użycie",
@@ -4821,6 +4833,7 @@ export const pl: MessageDict = {
"plans.sub.creditsSuffix": "Ulepsz Starter → Growth → Business przez Checkout lub skontaktuj się z działem sprzedaży w sprawie Enterprise.",
"plans.sub.none": "Nie przypisano jeszcze planu (np. po pominiętej migracji). Wybierz plan poniżej — pojemność nie jest Nieograniczona, dopóki Checkout lub administrator nie przypisze planu.",
"plans.stripeHint": "Upgrade'y samoobsługowe korzystają ze Stripe Checkout.",
+ "plans.checkoutUnavailable": "Checkout samoobslugowy jest niedostepny, dopoki Stripe nie zostanie skonfigurowany. Skontaktuj sie ze sprzedaza, aby uzyskac plan.",
"plans.fallbackName": "Plan taryfowy",
"plans.fallbackDescription": "Pojemność dla Twojego katalogu",
"plans.badge.current": "Aktualny",
diff --git a/apps/web/src/lib/i18n/messages/pt.ts b/apps/web/src/lib/i18n/messages/pt.ts
index 54bc694..4563944 100644
--- a/apps/web/src/lib/i18n/messages/pt.ts
+++ b/apps/web/src/lib/i18n/messages/pt.ts
@@ -802,6 +802,16 @@ export const pt: MessageDict = {
"admin.users.colCredits": "Créditos",
"admin.users.colLanguage": "Idioma",
"admin.users.assignPlan": "Atribuir plano",
+ "admin.users.cloneCatalog": "Copiar para a minha empresa",
+ "admin.users.cloneCatalogAria": "Copiar o catálogo de {name} para a sua empresa sandbox",
+ "admin.users.cloneCatalogTitle": "Copiar catálogo da empresa",
+ "admin.users.cloneCatalogDesc": "Permanece na sua conta de admin. Os dados de origem não são alterados.",
+ "admin.users.cloneCatalogFrom": "Origem: {name}",
+ "admin.users.cloneCatalogInto": "Destino: {name}",
+ "admin.users.cloneCatalogWarning": "Substitui categorias, feeds, produtos, atributos, mappings e marca no destino. Plano, faturação, membros, chaves API e conectores de loja não são copiados.",
+ "admin.users.cloneCatalogCancel": "Cancelar",
+ "admin.users.cloneCatalogConfirm": "Copiar catálogo",
+ "admin.users.cloneDestFallback": "a sua empresa sandbox",
"admin.users.noUsers": "Nenhum utilizador corresponde a este filtro.",
"admin.users.noCompanies": "Nenhuma empresa corresponde a este filtro.",
"admin.users.assignRoleTitle": "Atribuir função de equipa",
@@ -2285,6 +2295,7 @@ export const pt: MessageDict = {
"flash.processing.retried": "\"{name}\" em fila para nova tentativa.",
"flash.admin.staffRoleUpdated": "Função de pessoal atualizada para {email}.",
"flash.admin.staffRoleUnavailable": "As atualizações de função de pessoal ainda não estão disponíveis neste servidor.",
+ "flash.admin.catalogCloned": "Copiado {source} para {dest}: {products} produtos, {categories} categorias.",
"flash.admin.planAssigned": "Plano atribuído a {name}.",
"flash.admin.planAssignedShort": "Plano atribuído.",
"flash.admin.creditsUpdated": "Créditos atualizados.",
@@ -3387,6 +3398,7 @@ export const pt: MessageDict = {
"billing.needCapacity": "Precisa de mais capacidade?",
"billing.selfServeUpgrade": "Atualização self-service para Starter, Growth ou Business",
"billing.viaStripe": "via Stripe",
+ "billing.checkoutUnavailable": "As compras self-service nao estao disponiveis ate o Stripe ser configurado. Contacte as vendas para um plano.",
"billing.upgradeToGrowth": "Atualizar para Growth",
"billing.askAdminPlan": "Peça a um administrador da empresa para alterar o plano.",
"billing.usage": "Uso",
@@ -4821,6 +4833,7 @@ export const pt: MessageDict = {
"plans.sub.creditsSuffix": "Melhore Starter → Growth → Business com Checkout, ou fale com as vendas para Enterprise.",
"plans.sub.none": "Ainda não há um plano atribuído (por exemplo após uma migração omitida). Escolha um plano abaixo — a capacidade não é Ilimitada até que o Checkout ou um administrador atribua um.",
"plans.stripeHint": "Os upgrades de autosserviço utilizam o Stripe Checkout.",
+ "plans.checkoutUnavailable": "O checkout self-service nao esta disponivel ate o Stripe ser configurado. Contacte as vendas para um plano.",
"plans.fallbackName": "Plano",
"plans.fallbackDescription": "Capacidade para o seu catálogo",
"plans.badge.current": "Atual",
diff --git a/apps/web/src/lib/stripe-billing.ts b/apps/web/src/lib/stripe-billing.ts
index 7af72cd..13e84d9 100644
--- a/apps/web/src/lib/stripe-billing.ts
+++ b/apps/web/src/lib/stripe-billing.ts
@@ -19,6 +19,17 @@ export type StripeStatus = {
subscription_status?: string;
};
+/** Live Stripe for all company admins; mock checkout only for platform admins. */
+export function canSelfServePurchase(
+ stripe: StripeStatus | null | undefined,
+ opts?: { isPlatformAdmin?: boolean }
+): boolean {
+ if (!stripe) return false;
+ if (stripe.configured && !stripe.mock) return true;
+ if (stripe.mock && opts?.isPlatformAdmin) return true;
+ return false;
+}
+
export type CheckoutResult = {
url: string;
mock?: boolean;
diff --git a/apps/web/src/routes/admin/users/+page.svelte b/apps/web/src/routes/admin/users/+page.svelte
index 09e1ddb..ac7808a 100644
--- a/apps/web/src/routes/admin/users/+page.svelte
+++ b/apps/web/src/routes/admin/users/+page.svelte
@@ -10,6 +10,7 @@
PAGE_SIZE,
STAFF_ROLE_OPTIONS,
assignAdminPlan,
+ cloneAdminCompanyCatalog,
companyPlanBadge,
isStaffRoleApiUnavailable,
listAdminCompanies,
@@ -24,6 +25,7 @@
type AdminOrgUser,
type PlatformStaffRole
} from "$lib/admin-orgs";
+ import { authSession } from "$lib/auth-session.svelte";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import Spinner from "$lib/components/Spinner.svelte";
@@ -52,7 +54,7 @@
TabsList,
TabsTrigger
} from "$lib/components/ui";
- import { Building2, KeyRound, Search, Shield, UserPlus, Users } from "@lucide/svelte";
+ import { Building2, Copy, KeyRound, Search, Shield, UserPlus, Users } from "@lucide/svelte";
type TabKey = "users" | "companies";
@@ -88,11 +90,22 @@
let assignCompany = $state(null);
let assignPlanId = $state("");
+ let cloneOpen = $state(false);
+ let cloneCompany = $state(null);
+
const usersPage = $derived(Math.floor(usersOffset / PAGE_SIZE) + 1);
const usersPages = $derived(Math.max(1, Math.ceil(usersTotal / PAGE_SIZE)));
const companiesPage = $derived(Math.floor(companiesOffset / PAGE_SIZE) + 1);
const companiesPages = $derived(Math.max(1, Math.ceil(companiesTotal / PAGE_SIZE)));
+ const cloneDestLabel = $derived.by(() => {
+ const me = authSession.me;
+ const home = me?.staff_home_company;
+ if (home?.name) return home.name;
+ if (me?.company?.name) return me.company.name;
+ return i18n.t("admin.users.cloneDestFallback");
+ });
+
onMount(async () => {
const gate = await requirePlatformAdmin();
if (!gate.ok) {
@@ -342,6 +355,44 @@
}
}
+ function openCloneDialog(company: AdminOrgCompany) {
+ cloneCompany = company;
+ cloneOpen = true;
+ error = "";
+ success = "";
+ }
+
+ async function confirmCloneCatalog() {
+ if (!cloneCompany) return;
+ busy = true;
+ error = "";
+ success = "";
+ try {
+ const me = authSession.me;
+ const destId =
+ me?.staff_home_company_id?.trim() ||
+ me?.staff_home_company?.id?.trim() ||
+ undefined;
+ const res = await cloneAdminCompanyCatalog(cloneCompany.id, {
+ destCompanyId: destId
+ });
+ const products = Number(res.counts?.raw_products ?? 0);
+ const categories = Number(res.counts?.categories ?? 0);
+ success = i18n.t("flash.admin.catalogCloned", {
+ source: cloneCompany.name,
+ dest: cloneDestLabel,
+ products: String(products),
+ categories: String(categories)
+ });
+ cloneOpen = false;
+ cloneCompany = null;
+ } catch (err) {
+ error = failureMessage(err, "Clone catalog failed");
+ } finally {
+ busy = false;
+ }
+ }
+
async function sendSetPasswordEmails(userId?: string) {
busy = true;
error = "";
@@ -712,14 +763,28 @@
{company.language || "—"}
-
+