From bd762d1ccfdcabb495fef93bfda4f9302c13ee71 Mon Sep 17 00:00:00 2001 From: GreenEclipse Date: Sat, 22 Aug 2026 19:23:15 +0200 Subject: [PATCH] fix --- apps/api/internal/catalog/service.go | 32 ++- .../src/lib/components/DashboardStats.svelte | 9 +- .../products/ProductEditPanel.svelte | 32 +-- .../products/ProductImageGallery.svelte | 263 ++++++++++++++++++ .../components/products/ProductTable.svelte | 11 +- .../components/products/ProductThumb.svelte | 57 ++++ apps/web/src/lib/components/products/types.ts | 5 +- .../settings/MemberPermissionsDialog.svelte | 217 ++++++++++----- apps/web/src/lib/i18n/messages/de.ts | 29 +- apps/web/src/lib/i18n/messages/en.ts | 29 +- apps/web/src/lib/i18n/messages/es.ts | 29 +- apps/web/src/lib/i18n/messages/fr.ts | 29 +- apps/web/src/lib/i18n/messages/it.ts | 29 +- apps/web/src/lib/i18n/messages/ja.ts | 29 +- apps/web/src/lib/i18n/messages/nl.ts | 29 +- apps/web/src/lib/i18n/messages/pl.ts | 29 +- apps/web/src/lib/i18n/messages/pt.ts | 29 +- apps/web/src/lib/member-permissions.test.ts | 127 +++++++++ apps/web/src/lib/member-permissions.ts | 115 +++++++- apps/web/src/lib/product-images.ts | 15 + apps/web/src/routes/dashboard/+page.svelte | 14 +- 21 files changed, 1047 insertions(+), 111 deletions(-) create mode 100644 apps/web/src/lib/components/products/ProductImageGallery.svelte create mode 100644 apps/web/src/lib/components/products/ProductThumb.svelte diff --git a/apps/api/internal/catalog/service.go b/apps/api/internal/catalog/service.go index f194bc5..d929e0d 100644 --- a/apps/api/internal/catalog/service.go +++ b/apps/api/internal/catalog/service.go @@ -673,7 +673,7 @@ const ( processedPreferredDescriptionSQL = `COALESCE(NULLIF(p.processed_description, ''), NULLIF(p.description, ''), NULLIF(r.mapped_data->>'description', ''), '')` processedHasNameSQL = `(` + processedPreferredNameSQL + ` <> '')` processedHasDescriptionSQL = `(` + processedPreferredDescriptionSQL + ` <> '')` - processedHasCategorySQL = `(COALESCE(NULLIF(p.category, ''), '') <> '' AND lower(p.category) <> 'none')` + processedHasCategorySQL = `(COALESCE(NULLIF(p.category, ''), '') <> '' AND lower(p.category) <> 'none')` // Resolve display name / canonical unique_id when products store unique_id, UUID id, or name. processedCategoryResolveJoin = ` LEFT JOIN LATERAL ( @@ -770,8 +770,34 @@ var ( OR COALESCE(NULLIF(trim(r.mapped_data->>'eprel'), ''), '') <> '' OR COALESCE(NULLIF(trim(r.mapped_data->>'EPRELID'), ''), '') <> '' )` + + // listMainImageSQL is the first usable image URL for a product row, so the list + // can show a thumbnail without shipping the whole mapped_data blob per row. + // + // Key order mirrors catalog.ExtractProductImages (raw_v1.go) and + // productImageUrls (apps/web/src/lib/product-images.ts): main_image is often an + // empty string while `moreimages` / `additional_image_urls` hold a comma-joined + // list, so those are split on the first entry. `%s` is the mapped_data column ref. + listMainImageTemplate = `COALESCE( + NULLIF(trim(%[1]s->>'image_url'), ''), + NULLIF(trim(%[1]s->>'main_image'), ''), + NULLIF(trim(%[1]s->>'image_link'), ''), + NULLIF(trim(%[1]s->>'mainImage'), ''), + NULLIF(trim(%[1]s->>'imageUrl'), ''), + NULLIF(trim(%[1]s->'images'->>0), ''), + NULLIF(trim(split_part(%[1]s->>'moreimages', ',', 1)), ''), + NULLIF(trim(split_part(%[1]s->>'more_images', ',', 1)), ''), + NULLIF(trim(split_part(%[1]s->>'additional_image_urls', ',', 1)), ''), + NULLIF(trim(split_part(%[1]s->>'additional_image_link', ',', 1)), '') + )` ) +// listMainImageSQL renders listMainImageTemplate for a mapped_data column reference +// ("rp.mapped_data" on the raw list, "r.mapped_data" on the processed join). +func listMainImageSQL(mappedDataRef string) string { + return fmt.Sprintf(listMainImageTemplate, mappedDataRef) +} + func normalizeCoverageFilter(raw string) string { c := strings.ToLower(strings.TrimSpace(raw)) c = strings.ReplaceAll(c, "-", "_") @@ -958,6 +984,7 @@ func (s *Service) ListRawProducts(ctx context.Context, companyID uuid.UUID, f Li (COALESCE(NULLIF(trim(rp.mapped_data->>'description'), ''), '') <> '') AS has_description, (COALESCE(NULLIF(trim(rp.mapped_data->>'category'), ''), '') <> '' AND lower(trim(rp.mapped_data->>'category')) <> 'none') AS has_category, `+strings.ReplaceAll(processedHasFeedAttributesSQL, "r.mapped_data", "rp.mapped_data")+` AS has_attributes, + `+listMainImageSQL("rp.mapped_data")+` AS main_image, rp.created_at, rp.updated_at %s%s WHERE %s ORDER BY %s @@ -971,6 +998,7 @@ func (s *Service) ListRawProducts(ctx context.Context, companyID uuid.UUID, f Li "name", "category", "category_name", "category_unique_id", "feed_name", "sync_changes", "has_name", "has_description", "has_category", "has_attributes", + "main_image", "created_at", "updated_at", }) }, @@ -1113,6 +1141,7 @@ func (s *Service) ListProcessedProducts(ctx context.Context, companyID uuid.UUID `+processedHasAttributesSQL+` AS has_attributes, `+processedHasProcessedAttributesSQL+` AS has_processed_attributes, `+processedHasEprelSQL+` AS has_eprel, + `+listMainImageSQL("r.mapped_data")+` AS main_image, p.created_at, p.updated_at FROM processed_products p LEFT JOIN raw_products r ON r.id = p.raw_product_id @@ -1129,6 +1158,7 @@ func (s *Service) ListProcessedProducts(ctx context.Context, companyID uuid.UUID "feed_name", "feed_last_synced_at", "raw_updated_at", "has_name", "has_processed_name", "has_description", "has_processed_description", "has_category", "has_attributes", "has_processed_attributes", "has_eprel", + "main_image", "created_at", "updated_at", }) if err != nil { diff --git a/apps/web/src/lib/components/DashboardStats.svelte b/apps/web/src/lib/components/DashboardStats.svelte index 3a72edf..dc1faa5 100644 --- a/apps/web/src/lib/components/DashboardStats.svelte +++ b/apps/web/src/lib/components/DashboardStats.svelte @@ -8,6 +8,8 @@ } from "$lib/billing-display"; import { i18n } from "$lib/i18n"; import { FolderTree, ListTree, Package, Rss, Sparkles } from "@lucide/svelte"; + import { planCapabilities } from "$lib/plan-capabilities.svelte"; + import { featureKeyForHref } from "$lib/plan-capabilities"; let { stats, @@ -89,7 +91,12 @@ icon: Rss, tour: "stats-feeds" } - ]); + // Every tile is a link, so hide the ones this member cannot open — a plan gate + // or the owner's per-member overlay (settings > team) can deny any of them. + ].filter((card) => { + const key = featureKeyForHref(card.href); + return !key || planCapabilities.can(key); + }));
- {#if imageUrls.length > 0} -
-

{i18n.t("products.edit.images")}

-
- {#each imageUrls as src, i} - - {i18n.t("products.edit.imageAlt", - - {/each} -
-
- {/if} + diff --git a/apps/web/src/lib/components/products/ProductImageGallery.svelte b/apps/web/src/lib/components/products/ProductImageGallery.svelte new file mode 100644 index 0000000..f14d949 --- /dev/null +++ b/apps/web/src/lib/components/products/ProductImageGallery.svelte @@ -0,0 +1,263 @@ + + + + +{#if images.length > 0} +
+
+

{i18n.t("products.edit.images")}

+ + {i18n.t("products.edit.imageCount", { count: images.length })} + +
+
+ {#each images as src, i (src)} + + {/each} +
+
+{/if} + +{#if open && current} + + + +{/if} diff --git a/apps/web/src/lib/components/products/ProductTable.svelte b/apps/web/src/lib/components/products/ProductTable.svelte index db754ad..0fd98e6 100644 --- a/apps/web/src/lib/components/products/ProductTable.svelte +++ b/apps/web/src/lib/components/products/ProductTable.svelte @@ -6,6 +6,7 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte"; import VirtualList from "$lib/components/VirtualList.svelte"; import { cn } from "$lib/utils"; import ProductStatusBadge from "./ProductStatusBadge.svelte"; + import ProductThumb from "./ProductThumb.svelte"; import { categoryDisplayName, enrichmentChipClass, @@ -21,6 +22,7 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte"; productLeanIssueLabels, productLeanIssues, productSku, + productThumbUrl, type CategoryOption, type EnrichmentPiece, type ProductRow @@ -530,7 +532,13 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte"; />
-
+
+ +
{productSku(product)}
{#if editingId !== null && String(editingId) === String(product.id)} @@ -639,6 +647,7 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte"; {/if}
+
{/if}
diff --git a/apps/web/src/lib/i18n/messages/de.ts b/apps/web/src/lib/i18n/messages/de.ts index 46db7ca..5027504 100644 --- a/apps/web/src/lib/i18n/messages/de.ts +++ b/apps/web/src/lib/i18n/messages/de.ts @@ -5678,5 +5678,32 @@ export const de: MessageDict = { "settings.permissions.loadFailed": "Berechtigungen konnten nicht geladen werden", "settings.permissions.restrictedBadge": "Eingeschränkt", "settings.permissions.restrictedHint": "{count} Bereiche deaktiviert", - "settings.tabLockedByAdmin": "Vom Unternehmensinhaber deaktiviert" + "settings.tabLockedByAdmin": "Vom Unternehmensinhaber deaktiviert", + "products.table.noImage": "Kein Bild", + "products.edit.imageCount": "{count} Bilder", + "products.edit.openImage": "Vollbild öffnen", + "products.edit.openImageAt": "Bild {index} im Vollbild öffnen", + "products.edit.openImageNewTab": "Original in neuem Tab öffnen", + "products.edit.galleryLabel": "Bilder von {name}", + "products.edit.imageOf": "Bild {index} von {total}", + "products.edit.previousImage": "Vorheriges Bild", + "products.edit.nextImage": "Nächstes Bild", + "products.edit.imageFailed": "Dieses Bild konnte nicht vom Lieferanten geladen werden.", + "settings.permissions.roleHeading": "Rolle", + "settings.permissions.roleHint": "Wählen Sie eine Rolle — die passenden Bereiche werden automatisch gesetzt. Unten können Sie nachjustieren.", + "settings.permissions.role.full": "Voller Zugriff", + "settings.permissions.role.full.desc": "Alles, was Ihr Tarif enthält.", + "settings.permissions.role.manager": "Manager", + "settings.permissions.role.manager.desc": "Alles außer Abrechnung, Team und API-Schlüsseln.", + "settings.permissions.role.catalog_editor": "Katalogpflege", + "settings.permissions.role.catalog_editor.desc": "Produkte, Kategorien, Attribute und Felder. Keine Feeds, Shops oder Einstellungen.", + "settings.permissions.role.feed_operator": "Feed-Betrieb", + "settings.permissions.role.feed_operator.desc": "Feeds, Mapping, Exporte und Jobs, dazu der Katalog.", + "settings.permissions.role.marketing": "Marketing", + "settings.permissions.role.marketing.desc": "Kampagnen, Kalender, SEO, Marke und Bewertungen, dazu der Katalog.", + "settings.permissions.role.viewer": "Nur Ansicht", + "settings.permissions.role.viewer.desc": "Dashboard, Katalog und Feeds ansehen. Sonst nichts.", + "settings.permissions.role.custom": "Benutzerdefiniert", + "settings.permissions.role.custom.desc": "Sie haben Bereiche einzeln gewählt — das entspricht keiner Standardrolle.", + "settings.permissions.advanced": "Einzelne Bereiche feinjustieren" }; diff --git a/apps/web/src/lib/i18n/messages/en.ts b/apps/web/src/lib/i18n/messages/en.ts index 6d946fa..f3048ac 100644 --- a/apps/web/src/lib/i18n/messages/en.ts +++ b/apps/web/src/lib/i18n/messages/en.ts @@ -5764,5 +5764,32 @@ export const en: MessageDict = { "settings.permissions.loadFailed": "Could not load permissions", "settings.permissions.restrictedBadge": "Restricted", "settings.permissions.restrictedHint": "{count} areas turned off", - "settings.tabLockedByAdmin": "Turned off by your company owner" + "settings.tabLockedByAdmin": "Turned off by your company owner", + "products.table.noImage": "No image", + "products.edit.imageCount": "{count} images", + "products.edit.openImage": "Open full screen", + "products.edit.openImageAt": "Open image {index} full screen", + "products.edit.openImageNewTab": "Open original in a new tab", + "products.edit.galleryLabel": "Images for {name}", + "products.edit.imageOf": "Image {index} of {total}", + "products.edit.previousImage": "Previous image", + "products.edit.nextImage": "Next image", + "products.edit.imageFailed": "This image could not be loaded from the supplier.", + "settings.permissions.roleHeading": "Role", + "settings.permissions.roleHint": "Pick a role and the right areas are selected for you. Fine-tune below if you need to.", + "settings.permissions.role.full": "Full access", + "settings.permissions.role.full.desc": "Everything your plan includes.", + "settings.permissions.role.manager": "Manager", + "settings.permissions.role.manager.desc": "Everything except billing, teammates and API keys.", + "settings.permissions.role.catalog_editor": "Catalog editor", + "settings.permissions.role.catalog_editor.desc": "Products, categories, attributes and fields. No feeds, stores or settings.", + "settings.permissions.role.feed_operator": "Feed operator", + "settings.permissions.role.feed_operator.desc": "Feeds, mapping, exports and processing jobs, plus the catalog.", + "settings.permissions.role.marketing": "Marketing", + "settings.permissions.role.marketing.desc": "Campaigns, calendar, SEO, brand and reviews, plus the catalog.", + "settings.permissions.role.viewer": "Viewer", + "settings.permissions.role.viewer.desc": "Browse the dashboard, catalog and feeds. Nothing else.", + "settings.permissions.role.custom": "Custom", + "settings.permissions.role.custom.desc": "You picked areas by hand, so this matches no standard role.", + "settings.permissions.advanced": "Fine-tune individual areas" }; diff --git a/apps/web/src/lib/i18n/messages/es.ts b/apps/web/src/lib/i18n/messages/es.ts index 103c280..2c8bc0f 100644 --- a/apps/web/src/lib/i18n/messages/es.ts +++ b/apps/web/src/lib/i18n/messages/es.ts @@ -5687,5 +5687,32 @@ export const es: MessageDict = { "settings.permissions.loadFailed": "No se pudieron cargar los permisos", "settings.permissions.restrictedBadge": "Restringido", "settings.permissions.restrictedHint": "{count} secciones desactivadas", - "settings.tabLockedByAdmin": "Desactivado por el propietario de tu empresa" + "settings.tabLockedByAdmin": "Desactivado por el propietario de tu empresa", + "products.table.noImage": "Sin imagen", + "products.edit.imageCount": "{count} imágenes", + "products.edit.openImage": "Abrir en pantalla completa", + "products.edit.openImageAt": "Abrir la imagen {index} en pantalla completa", + "products.edit.openImageNewTab": "Abrir el original en una pestaña nueva", + "products.edit.galleryLabel": "Imágenes de {name}", + "products.edit.imageOf": "Imagen {index} de {total}", + "products.edit.previousImage": "Imagen anterior", + "products.edit.nextImage": "Imagen siguiente", + "products.edit.imageFailed": "No se pudo cargar esta imagen desde el proveedor.", + "settings.permissions.roleHeading": "Rol", + "settings.permissions.roleHint": "Elige un rol y se seleccionarán las secciones adecuadas. Ajústalo abajo si lo necesitas.", + "settings.permissions.role.full": "Acceso completo", + "settings.permissions.role.full.desc": "Todo lo que incluye tu plan.", + "settings.permissions.role.manager": "Responsable", + "settings.permissions.role.manager.desc": "Todo excepto facturación, equipo y claves API.", + "settings.permissions.role.catalog_editor": "Editor de catálogo", + "settings.permissions.role.catalog_editor.desc": "Productos, categorías, atributos y campos. Sin feeds, tiendas ni ajustes.", + "settings.permissions.role.feed_operator": "Operador de feeds", + "settings.permissions.role.feed_operator.desc": "Feeds, mapeo, exportaciones y trabajos, además del catálogo.", + "settings.permissions.role.marketing": "Marketing", + "settings.permissions.role.marketing.desc": "Campañas, calendario, SEO, marca y reseñas, además del catálogo.", + "settings.permissions.role.viewer": "Solo consulta", + "settings.permissions.role.viewer.desc": "Ver el panel, el catálogo y los feeds. Nada más.", + "settings.permissions.role.custom": "Personalizado", + "settings.permissions.role.custom.desc": "Has elegido las secciones a mano, así que no coincide con ningún rol estándar.", + "settings.permissions.advanced": "Ajustar secciones concretas" }; diff --git a/apps/web/src/lib/i18n/messages/fr.ts b/apps/web/src/lib/i18n/messages/fr.ts index 1dba6d2..843de24 100644 --- a/apps/web/src/lib/i18n/messages/fr.ts +++ b/apps/web/src/lib/i18n/messages/fr.ts @@ -5687,5 +5687,32 @@ export const fr: MessageDict = { "settings.permissions.loadFailed": "Impossible de charger les autorisations", "settings.permissions.restrictedBadge": "Restreint", "settings.permissions.restrictedHint": "{count} sections désactivées", - "settings.tabLockedByAdmin": "Désactivé par le propriétaire de votre entreprise" + "settings.tabLockedByAdmin": "Désactivé par le propriétaire de votre entreprise", + "products.table.noImage": "Pas d’image", + "products.edit.imageCount": "{count} images", + "products.edit.openImage": "Ouvrir en plein écran", + "products.edit.openImageAt": "Ouvrir l’image {index} en plein écran", + "products.edit.openImageNewTab": "Ouvrir l’original dans un nouvel onglet", + "products.edit.galleryLabel": "Images de {name}", + "products.edit.imageOf": "Image {index} sur {total}", + "products.edit.previousImage": "Image précédente", + "products.edit.nextImage": "Image suivante", + "products.edit.imageFailed": "Impossible de charger cette image depuis le fournisseur.", + "settings.permissions.roleHeading": "Rôle", + "settings.permissions.roleHint": "Choisissez un rôle et les sections adaptées sont sélectionnées. Ajustez ci-dessous si besoin.", + "settings.permissions.role.full": "Accès complet", + "settings.permissions.role.full.desc": "Tout ce que votre forfait inclut.", + "settings.permissions.role.manager": "Responsable", + "settings.permissions.role.manager.desc": "Tout sauf la facturation, l’équipe et les clés API.", + "settings.permissions.role.catalog_editor": "Éditeur de catalogue", + "settings.permissions.role.catalog_editor.desc": "Produits, catégories, attributs et champs. Ni flux, ni boutiques, ni réglages.", + "settings.permissions.role.feed_operator": "Opérateur de flux", + "settings.permissions.role.feed_operator.desc": "Flux, mapping, exports et traitements, plus le catalogue.", + "settings.permissions.role.marketing": "Marketing", + "settings.permissions.role.marketing.desc": "Campagnes, calendrier, SEO, marque et avis, plus le catalogue.", + "settings.permissions.role.viewer": "Lecture seule", + "settings.permissions.role.viewer.desc": "Consulter le tableau de bord, le catalogue et les flux. Rien d’autre.", + "settings.permissions.role.custom": "Personnalisé", + "settings.permissions.role.custom.desc": "Vous avez choisi les sections à la main : cela ne correspond à aucun rôle standard.", + "settings.permissions.advanced": "Ajuster des sections précises" }; diff --git a/apps/web/src/lib/i18n/messages/it.ts b/apps/web/src/lib/i18n/messages/it.ts index 48e6e39..0be9d5c 100644 --- a/apps/web/src/lib/i18n/messages/it.ts +++ b/apps/web/src/lib/i18n/messages/it.ts @@ -5687,5 +5687,32 @@ export const it: MessageDict = { "settings.permissions.loadFailed": "Impossibile caricare le autorizzazioni", "settings.permissions.restrictedBadge": "Limitato", "settings.permissions.restrictedHint": "{count} sezioni disattivate", - "settings.tabLockedByAdmin": "Disattivato dal proprietario della tua azienda" + "settings.tabLockedByAdmin": "Disattivato dal proprietario della tua azienda", + "products.table.noImage": "Nessuna immagine", + "products.edit.imageCount": "{count} immagini", + "products.edit.openImage": "Apri a schermo intero", + "products.edit.openImageAt": "Apri l’immagine {index} a schermo intero", + "products.edit.openImageNewTab": "Apri l’originale in una nuova scheda", + "products.edit.galleryLabel": "Immagini di {name}", + "products.edit.imageOf": "Immagine {index} di {total}", + "products.edit.previousImage": "Immagine precedente", + "products.edit.nextImage": "Immagine successiva", + "products.edit.imageFailed": "Impossibile caricare questa immagine dal fornitore.", + "settings.permissions.roleHeading": "Ruolo", + "settings.permissions.roleHint": "Scegli un ruolo e le sezioni giuste vengono selezionate da sole. Puoi rifinire qui sotto.", + "settings.permissions.role.full": "Accesso completo", + "settings.permissions.role.full.desc": "Tutto ciò che include il tuo piano.", + "settings.permissions.role.manager": "Responsabile", + "settings.permissions.role.manager.desc": "Tutto tranne fatturazione, team e chiavi API.", + "settings.permissions.role.catalog_editor": "Editor del catalogo", + "settings.permissions.role.catalog_editor.desc": "Prodotti, categorie, attributi e campi. Niente feed, store o impostazioni.", + "settings.permissions.role.feed_operator": "Operatore feed", + "settings.permissions.role.feed_operator.desc": "Feed, mappatura, esportazioni e job, più il catalogo.", + "settings.permissions.role.marketing": "Marketing", + "settings.permissions.role.marketing.desc": "Campagne, calendario, SEO, brand e recensioni, più il catalogo.", + "settings.permissions.role.viewer": "Sola consultazione", + "settings.permissions.role.viewer.desc": "Consultare dashboard, catalogo e feed. Nient’altro.", + "settings.permissions.role.custom": "Personalizzato", + "settings.permissions.role.custom.desc": "Hai scelto le sezioni a mano, quindi non corrisponde a nessun ruolo standard.", + "settings.permissions.advanced": "Regola le singole sezioni" }; diff --git a/apps/web/src/lib/i18n/messages/ja.ts b/apps/web/src/lib/i18n/messages/ja.ts index 301915a..9127cdc 100644 --- a/apps/web/src/lib/i18n/messages/ja.ts +++ b/apps/web/src/lib/i18n/messages/ja.ts @@ -5687,5 +5687,32 @@ export const ja: MessageDict = { "settings.permissions.loadFailed": "権限を読み込めませんでした", "settings.permissions.restrictedBadge": "制限あり", "settings.permissions.restrictedHint": "{count} 件のエリアを無効化", - "settings.tabLockedByAdmin": "会社のオーナーが無効にしています" + "settings.tabLockedByAdmin": "会社のオーナーが無効にしています", + "products.table.noImage": "画像なし", + "products.edit.imageCount": "画像 {count} 件", + "products.edit.openImage": "全画面で開く", + "products.edit.openImageAt": "{index} 番目の画像を全画面で開く", + "products.edit.openImageNewTab": "元画像を新しいタブで開く", + "products.edit.galleryLabel": "{name} の画像", + "products.edit.imageOf": "{total} 件中 {index} 件目", + "products.edit.previousImage": "前の画像", + "products.edit.nextImage": "次の画像", + "products.edit.imageFailed": "仕入先からこの画像を読み込めませんでした。", + "settings.permissions.roleHeading": "ロール", + "settings.permissions.roleHint": "ロールを選ぶと必要なエリアが自動で選択されます。下で微調整もできます。", + "settings.permissions.role.full": "フルアクセス", + "settings.permissions.role.full.desc": "プランに含まれるすべて。", + "settings.permissions.role.manager": "マネージャー", + "settings.permissions.role.manager.desc": "請求・メンバー・API キー以外のすべて。", + "settings.permissions.role.catalog_editor": "カタログ編集", + "settings.permissions.role.catalog_editor.desc": "商品・カテゴリ・属性・項目。フィードやストア、設定は不可。", + "settings.permissions.role.feed_operator": "フィード担当", + "settings.permissions.role.feed_operator.desc": "フィード、マッピング、エクスポート、ジョブとカタログ。", + "settings.permissions.role.marketing": "マーケティング", + "settings.permissions.role.marketing.desc": "キャンペーン、カレンダー、SEO、ブランド、レビューとカタログ。", + "settings.permissions.role.viewer": "閲覧のみ", + "settings.permissions.role.viewer.desc": "ダッシュボード、カタログ、フィードの閲覧のみ。", + "settings.permissions.role.custom": "カスタム", + "settings.permissions.role.custom.desc": "エリアを個別に選択したため、標準ロールには一致しません。", + "settings.permissions.advanced": "エリアごとに細かく調整" }; diff --git a/apps/web/src/lib/i18n/messages/nl.ts b/apps/web/src/lib/i18n/messages/nl.ts index 12be630..87e8452 100644 --- a/apps/web/src/lib/i18n/messages/nl.ts +++ b/apps/web/src/lib/i18n/messages/nl.ts @@ -5687,5 +5687,32 @@ export const nl: MessageDict = { "settings.permissions.loadFailed": "Rechten konden niet worden geladen", "settings.permissions.restrictedBadge": "Beperkt", "settings.permissions.restrictedHint": "{count} onderdelen uitgeschakeld", - "settings.tabLockedByAdmin": "Uitgeschakeld door de bedrijfseigenaar" + "settings.tabLockedByAdmin": "Uitgeschakeld door de bedrijfseigenaar", + "products.table.noImage": "Geen afbeelding", + "products.edit.imageCount": "{count} afbeeldingen", + "products.edit.openImage": "Schermvullend openen", + "products.edit.openImageAt": "Afbeelding {index} schermvullend openen", + "products.edit.openImageNewTab": "Origineel in een nieuw tabblad openen", + "products.edit.galleryLabel": "Afbeeldingen van {name}", + "products.edit.imageOf": "Afbeelding {index} van {total}", + "products.edit.previousImage": "Vorige afbeelding", + "products.edit.nextImage": "Volgende afbeelding", + "products.edit.imageFailed": "Deze afbeelding kon niet bij de leverancier worden opgehaald.", + "settings.permissions.roleHeading": "Rol", + "settings.permissions.roleHint": "Kies een rol; de juiste onderdelen worden vanzelf aangevinkt. Hieronder kun je bijstellen.", + "settings.permissions.role.full": "Volledige toegang", + "settings.permissions.role.full.desc": "Alles wat je abonnement bevat.", + "settings.permissions.role.manager": "Manager", + "settings.permissions.role.manager.desc": "Alles behalve facturatie, teamleden en API-sleutels.", + "settings.permissions.role.catalog_editor": "Catalogusbeheer", + "settings.permissions.role.catalog_editor.desc": "Producten, categorieën, kenmerken en velden. Geen feeds, winkels of instellingen.", + "settings.permissions.role.feed_operator": "Feedbeheer", + "settings.permissions.role.feed_operator.desc": "Feeds, mapping, exports en taken, plus de catalogus.", + "settings.permissions.role.marketing": "Marketing", + "settings.permissions.role.marketing.desc": "Campagnes, kalender, SEO, merk en reviews, plus de catalogus.", + "settings.permissions.role.viewer": "Alleen bekijken", + "settings.permissions.role.viewer.desc": "Dashboard, catalogus en feeds bekijken. Verder niets.", + "settings.permissions.role.custom": "Aangepast", + "settings.permissions.role.custom.desc": "Je hebt onderdelen handmatig gekozen, dus dit komt met geen enkele standaardrol overeen.", + "settings.permissions.advanced": "Losse onderdelen bijstellen" }; diff --git a/apps/web/src/lib/i18n/messages/pl.ts b/apps/web/src/lib/i18n/messages/pl.ts index 07d712d..3f692e2 100644 --- a/apps/web/src/lib/i18n/messages/pl.ts +++ b/apps/web/src/lib/i18n/messages/pl.ts @@ -5687,5 +5687,32 @@ export const pl: MessageDict = { "settings.permissions.loadFailed": "Nie udało się wczytać uprawnień", "settings.permissions.restrictedBadge": "Ograniczony", "settings.permissions.restrictedHint": "Wyłączonych sekcji: {count}", - "settings.tabLockedByAdmin": "Wyłączone przez właściciela firmy" + "settings.tabLockedByAdmin": "Wyłączone przez właściciela firmy", + "products.table.noImage": "Brak zdjęcia", + "products.edit.imageCount": "Zdjęcia: {count}", + "products.edit.openImage": "Otwórz na pełnym ekranie", + "products.edit.openImageAt": "Otwórz zdjęcie {index} na pełnym ekranie", + "products.edit.openImageNewTab": "Otwórz oryginał w nowej karcie", + "products.edit.galleryLabel": "Zdjęcia produktu {name}", + "products.edit.imageOf": "Zdjęcie {index} z {total}", + "products.edit.previousImage": "Poprzednie zdjęcie", + "products.edit.nextImage": "Następne zdjęcie", + "products.edit.imageFailed": "Nie udało się pobrać tego zdjęcia od dostawcy.", + "settings.permissions.roleHeading": "Rola", + "settings.permissions.roleHint": "Wybierz rolę, a właściwe sekcje zaznaczą się same. Poniżej możesz je doprecyzować.", + "settings.permissions.role.full": "Pełny dostęp", + "settings.permissions.role.full.desc": "Wszystko, co obejmuje Twój plan.", + "settings.permissions.role.manager": "Menedżer", + "settings.permissions.role.manager.desc": "Wszystko poza płatnościami, zespołem i kluczami API.", + "settings.permissions.role.catalog_editor": "Redaktor katalogu", + "settings.permissions.role.catalog_editor.desc": "Produkty, kategorie, atrybuty i pola. Bez feedów, sklepów i ustawień.", + "settings.permissions.role.feed_operator": "Operator feedów", + "settings.permissions.role.feed_operator.desc": "Feedy, mapowanie, eksporty i zadania oraz katalog.", + "settings.permissions.role.marketing": "Marketing", + "settings.permissions.role.marketing.desc": "Kampanie, kalendarz, SEO, marka i opinie oraz katalog.", + "settings.permissions.role.viewer": "Tylko podgląd", + "settings.permissions.role.viewer.desc": "Przeglądanie pulpitu, katalogu i feedów. Nic więcej.", + "settings.permissions.role.custom": "Własna", + "settings.permissions.role.custom.desc": "Sekcje wybrano ręcznie, więc nie odpowiada to żadnej standardowej roli.", + "settings.permissions.advanced": "Dostosuj pojedyncze sekcje" }; diff --git a/apps/web/src/lib/i18n/messages/pt.ts b/apps/web/src/lib/i18n/messages/pt.ts index f1bfe5f..1bfd473 100644 --- a/apps/web/src/lib/i18n/messages/pt.ts +++ b/apps/web/src/lib/i18n/messages/pt.ts @@ -5687,5 +5687,32 @@ export const pt: MessageDict = { "settings.permissions.loadFailed": "Não foi possível carregar as permissões", "settings.permissions.restrictedBadge": "Restrito", "settings.permissions.restrictedHint": "{count} áreas desativadas", - "settings.tabLockedByAdmin": "Desativado pelo proprietário da sua empresa" + "settings.tabLockedByAdmin": "Desativado pelo proprietário da sua empresa", + "products.table.noImage": "Sem imagem", + "products.edit.imageCount": "{count} imagens", + "products.edit.openImage": "Abrir em ecrã inteiro", + "products.edit.openImageAt": "Abrir a imagem {index} em ecrã inteiro", + "products.edit.openImageNewTab": "Abrir o original num novo separador", + "products.edit.galleryLabel": "Imagens de {name}", + "products.edit.imageOf": "Imagem {index} de {total}", + "products.edit.previousImage": "Imagem anterior", + "products.edit.nextImage": "Imagem seguinte", + "products.edit.imageFailed": "Não foi possível carregar esta imagem do fornecedor.", + "settings.permissions.roleHeading": "Função", + "settings.permissions.roleHint": "Escolha uma função e as áreas certas são selecionadas. Pode ajustar em baixo.", + "settings.permissions.role.full": "Acesso total", + "settings.permissions.role.full.desc": "Tudo o que o seu plano inclui.", + "settings.permissions.role.manager": "Gestor", + "settings.permissions.role.manager.desc": "Tudo exceto faturação, equipa e chaves de API.", + "settings.permissions.role.catalog_editor": "Editor de catálogo", + "settings.permissions.role.catalog_editor.desc": "Produtos, categorias, atributos e campos. Sem feeds, lojas ou definições.", + "settings.permissions.role.feed_operator": "Operador de feeds", + "settings.permissions.role.feed_operator.desc": "Feeds, mapeamento, exportações e tarefas, mais o catálogo.", + "settings.permissions.role.marketing": "Marketing", + "settings.permissions.role.marketing.desc": "Campanhas, calendário, SEO, marca e avaliações, mais o catálogo.", + "settings.permissions.role.viewer": "Consulta", + "settings.permissions.role.viewer.desc": "Ver o painel, o catálogo e os feeds. Mais nada.", + "settings.permissions.role.custom": "Personalizado", + "settings.permissions.role.custom.desc": "Escolheu as áreas manualmente, por isso não corresponde a nenhuma função padrão.", + "settings.permissions.advanced": "Ajustar áreas individuais" }; diff --git a/apps/web/src/lib/member-permissions.test.ts b/apps/web/src/lib/member-permissions.test.ts index 2ec529a..65bf81c 100644 --- a/apps/web/src/lib/member-permissions.test.ts +++ b/apps/web/src/lib/member-permissions.test.ts @@ -9,6 +9,11 @@ import { permissionSummary, samePermissions, setPermissionAllowed, + CUSTOM_ROLE_ID, + PERMISSION_ROLES, + detectRole, + roleById, + rolePresetDenied, type PermissionCatalog } from "./member-permissions.ts"; @@ -161,3 +166,125 @@ describe("filterCatalog", () => { ); }); }); + + +/** A catalog wide enough to exercise section-level role grants. */ +const roleCatalog: PermissionCatalog = { + sections: [ + { + id: "dashboard", + entries: [{ key: "dashboard.stats", section: "dashboard", parent: "", plan_allowed: true }] + }, + { + id: "catalog", + entries: [ + { key: "catalog.products", section: "catalog", parent: "", plan_allowed: true }, + { + key: "catalog.products.tab_error", + section: "catalog", + parent: "catalog.products", + plan_allowed: true + }, + { key: "catalog.categories", section: "catalog", parent: "", plan_allowed: true } + ] + }, + { + id: "feeds", + entries: [ + { key: "feeds.list", section: "feeds", parent: "", plan_allowed: true }, + { key: "feeds.export_feeds", section: "feeds", parent: "", plan_allowed: true } + ] + }, + { + id: "marketing", + entries: [{ key: "marketing.campaigns", section: "marketing", parent: "", plan_allowed: true }] + }, + { + id: "integrations", + entries: [{ key: "integrations.email", section: "integrations", parent: "", plan_allowed: true }] + }, + { + id: "billing", + entries: [ + { key: "billing.overview", section: "billing", parent: "", plan_allowed: true }, + { key: "billing.checkout", section: "billing", parent: "", plan_allowed: true } + ] + }, + { + id: "settings", + entries: [ + { key: "settings.company", section: "settings", parent: "", plan_allowed: true }, + { key: "settings.api_keys", section: "settings", parent: "", plan_allowed: true }, + { key: "settings.team", section: "settings", parent: "", plan_allowed: true } + ] + }, + { + id: "support", + entries: [{ key: "support.center", section: "support", parent: "", plan_allowed: true }] + } + ] +}; + +describe("rolePresetDenied", () => { + it("full access denies nothing", () => { + assert.deepEqual(rolePresetDenied(roleCatalog, roleById("full")), []); + }); + + it("manager keeps the operation but not money, team or API keys", () => { + const denied = rolePresetDenied(roleCatalog, roleById("manager")); + assert.deepEqual(denied, ["billing.checkout", "settings.api_keys", "settings.team"]); + // Everything else in those sections stays open. + assert.equal(isDenied(denied, "billing.overview"), false); + assert.equal(isDenied(denied, "settings.company"), false); + assert.equal(isDenied(denied, "marketing.campaigns"), false); + }); + + it("catalog editor grants only dashboard, catalog and support", () => { + const denied = rolePresetDenied(roleCatalog, roleById("catalog_editor")); + assert.equal(isDenied(denied, "catalog.products"), false); + assert.equal(isDenied(denied, "catalog.products.tab_error"), false); + assert.equal(isDenied(denied, "support.center"), false); + assert.equal(isDenied(denied, "feeds.list"), true); + assert.equal(isDenied(denied, "marketing.campaigns"), true); + assert.equal(isDenied(denied, "settings.api_keys"), true); + // Denials collapse to roots — children are implied, never stored. + assert.equal(denied.includes("catalog.products.tab_error"), false); + }); + + it("marketing adds email on top of its sections", () => { + const denied = rolePresetDenied(roleCatalog, roleById("marketing")); + assert.equal(isDenied(denied, "marketing.campaigns"), false); + assert.equal(isDenied(denied, "integrations.email"), false); + assert.equal(isDenied(denied, "feeds.list"), true); + }); + + it("viewer grants browsing only", () => { + const denied = rolePresetDenied(roleCatalog, roleById("viewer")); + assert.equal(isDenied(denied, "catalog.products"), false); + assert.equal(isDenied(denied, "feeds.list"), false); + assert.equal(isDenied(denied, "feeds.export_feeds"), true); + assert.equal(isDenied(denied, "settings.company"), true); + }); + + it("returns nothing for an unknown role", () => { + assert.deepEqual(rolePresetDenied(roleCatalog, roleById("nope")), []); + }); +}); + +describe("detectRole", () => { + it("round-trips every preset", () => { + for (const role of PERMISSION_ROLES) { + const denied = rolePresetDenied(roleCatalog, role); + assert.equal(detectRole(roleCatalog, denied), role.id, `role ${role.id}`); + } + }); + + it("falls back to custom for a hand-tuned selection", () => { + const denied = setPermissionAllowed([], "catalog.categories", false, catalogKeys(roleCatalog)); + assert.equal(detectRole(roleCatalog, denied), CUSTOM_ROLE_ID); + }); + + it("an empty selection is full access, not custom", () => { + assert.equal(detectRole(roleCatalog, []), "full"); + }); +}); diff --git a/apps/web/src/lib/member-permissions.ts b/apps/web/src/lib/member-permissions.ts index 11bc509..0d744f4 100644 --- a/apps/web/src/lib/member-permissions.ts +++ b/apps/web/src/lib/member-permissions.ts @@ -91,7 +91,7 @@ export function setPermissionAllowed( if (!allowed) { for (const descendant of descendantsOf(key, allKeys)) next.delete(descendant); next.add(key); - return normalize(next, allKeys); + return normalizeDenied(next, allKeys); } next.delete(key); @@ -104,11 +104,11 @@ export function setPermissionAllowed( if (!openPath.has(sibling)) next.add(sibling); } } - return normalize(next, allKeys); + return normalizeDenied(next, allKeys); } /** Drop keys outside the catalog and denials already implied by a denied ancestor. */ -function normalize(denied: Set, allKeys: string[]): string[] { +export function normalizeDenied(denied: Set, allKeys: string[]): string[] { const known = new Set(allKeys); const out: string[] = []; for (const key of denied) { @@ -122,7 +122,7 @@ function normalize(denied: Set, allKeys: string[]): string[] { /** Deny every grantable key (the always-on shell keys are not in the catalog). */ export function denyAll(catalog: PermissionCatalog | null | undefined): string[] { const all = catalogKeys(catalog); - return normalize(new Set(all), all); + return normalizeDenied(new Set(all), all); } /** Allowed / total counts for the summary line, ignoring keys the plan already denies. */ @@ -170,3 +170,110 @@ export function filterCatalog( } return out; } + + +/** + * Role presets. + * + * Raw feature keys ("catalog.products.tab_error") are precise but unreadable to most + * owners, so the editor leads with a role and derives the checkbox state from it. + * A role grants whole dashboard sections, with a few key-level exceptions; anything + * not granted is denied, and parent-prefix denial collapses the stored list. + * + * Roles describe which AREAS a teammate can open — the model is page-level, not + * verb-level, so a role never implies "read-only" within an area it grants. + */ +export type PermissionRole = { + id: string; + /** Sections fully granted, or "all" for every section. */ + sections: "all" | string[]; + /** Extra keys granted outside the granted sections. */ + allowKeys?: string[]; + /** Keys denied even though their section is granted. */ + denyKeys?: string[]; +}; + +export const PERMISSION_ROLES: PermissionRole[] = [ + { id: "full", sections: "all" }, + { + // Runs the whole product operation, but money and account access stay with the owner. + id: "manager", + sections: "all", + denyKeys: [ + "settings.api_keys", + "settings.team", + "settings.team_invite", + "billing.checkout", + "billing.customer_portal", + "billing.quick_upgrade" + ] + }, + { id: "catalog_editor", sections: ["dashboard", "catalog", "support"] }, + { id: "feed_operator", sections: ["dashboard", "catalog", "feeds", "processing", "support"] }, + { + id: "marketing", + sections: ["dashboard", "catalog", "marketing", "support"], + allowKeys: ["integrations.email"] + }, + { + id: "viewer", + sections: ["dashboard", "support"], + allowKeys: [ + "catalog.products", + "catalog.categories", + "catalog.attributes", + "catalog.standard_fields", + "feeds.list" + ] + } +]; + +/** Sentinel returned by detectRole when the selection matches no preset. */ +export const CUSTOM_ROLE_ID = "custom"; + +export function roleById(id: string): PermissionRole | null { + return PERMISSION_ROLES.find((role) => role.id === id) ?? null; +} + +/** Top-level grantable entries (those with no grantable ancestor in the catalog). */ +function rootEntries(catalog: PermissionCatalog | null | undefined): PermissionCatalogEntry[] { + return (catalog?.sections ?? []).flatMap((section) => + section.entries.filter((entry) => !entry.parent) + ); +} + +/** The denied list a role produces for this company's catalog. */ +export function rolePresetDenied( + catalog: PermissionCatalog | null | undefined, + role: PermissionRole | null +): string[] { + if (!role) return []; + const all = catalogKeys(catalog); + const allowKeys = new Set(role.allowKeys ?? []); + const denyKeys = new Set(role.denyKeys ?? []); + const denied = new Set(); + + for (const entry of rootEntries(catalog)) { + const sectionGranted = + role.sections === "all" || role.sections.includes(entry.section); + if (denyKeys.has(entry.key) || (!sectionGranted && !allowKeys.has(entry.key))) { + denied.add(entry.key); + } + } + // Key-level exceptions below a granted root (e.g. deny settings.api_keys under settings). + for (const key of denyKeys) { + if (!isDenied(denied, key)) denied.add(key); + } + return normalizeDenied(denied, all); +} + +/** Which role the current selection corresponds to, or CUSTOM_ROLE_ID. */ +export function detectRole( + catalog: PermissionCatalog | null | undefined, + denied: string[] +): string { + for (const role of PERMISSION_ROLES) { + if (samePermissions(rolePresetDenied(catalog, role), denied)) return role.id; + } + return CUSTOM_ROLE_ID; +} diff --git a/apps/web/src/lib/product-images.ts b/apps/web/src/lib/product-images.ts index 267f3aa..62928dc 100644 --- a/apps/web/src/lib/product-images.ts +++ b/apps/web/src/lib/product-images.ts @@ -139,3 +139,18 @@ export function productImageUrls(product: ProductImageSource): string[] { } return out; } + +/** + * First usable image URL for a product row, or null. + * + * The lean product list omits mapped_data and instead ships a precomputed + * `main_image` column (catalog.listMainImageSQL); the detailed list and the editor + * carry mapped_data, so fall back to the full extractor there. + */ +export function productThumbUrl(product: ProductImageSource): string | null { + const row = asRecord(product); + if (!row) return null; + const direct = coerceImageUrl(row.main_image); + if (direct) return direct; + return productImageUrls(product)[0] ?? null; +} diff --git a/apps/web/src/routes/dashboard/+page.svelte b/apps/web/src/routes/dashboard/+page.svelte index 4895618..f31a199 100644 --- a/apps/web/src/routes/dashboard/+page.svelte +++ b/apps/web/src/routes/dashboard/+page.svelte @@ -335,6 +335,12 @@ const canMonitorJobs = $derived(planCapabilities.can("processing.monitor")); const canExportFeeds = $derived(planCapabilities.can("feeds.export_feeds")); + // Feeds / Products used to be assumed always-on. A per-member permission overlay + // (settings > team) can now deny either, so the shortcuts and workflow steps must + // gate on them too — otherwise the dashboard offers links that dead-end on the + // "access restricted" panel. + const canBrowseFeeds = $derived(planCapabilities.can("feeds.list")); + const canBrowseProducts = $derived(planCapabilities.can("catalog.products")); const planFlags = $derived({ name: typeof planInfo?.name === "string" ? planInfo.name : "", is_legacy: typeof planInfo?.is_legacy === "boolean" ? planInfo.is_legacy : undefined, @@ -419,7 +425,9 @@ done: processedTotal > 0, tour: "workflow-process" } - ]); + ].filter((step) => + step.id === "process" ? canBrowseProducts : canBrowseFeeds + )); {#if loading} @@ -804,6 +812,7 @@

{i18n.t("dashboard.quickLinksHint")}

+ {#if canBrowseFeeds} + {/if} + {#if canBrowseProducts} + {/if} {#if canMonitorJobs}