This commit is contained in:
2026-08-22 19:23:15 +02:00
parent 0c154254c3
commit bd762d1ccf
21 changed files with 1047 additions and 111 deletions
+30
View File
@@ -770,8 +770,34 @@ var (
OR COALESCE(NULLIF(trim(r.mapped_data->>'eprel'), ''), '') <> '' OR COALESCE(NULLIF(trim(r.mapped_data->>'eprel'), ''), '') <> ''
OR COALESCE(NULLIF(trim(r.mapped_data->>'EPRELID'), ''), '') <> '' 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 { func normalizeCoverageFilter(raw string) string {
c := strings.ToLower(strings.TrimSpace(raw)) c := strings.ToLower(strings.TrimSpace(raw))
c = strings.ReplaceAll(c, "-", "_") 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->>'description'), ''), '') <> '') AS has_description,
(COALESCE(NULLIF(trim(rp.mapped_data->>'category'), ''), '') <> '' AND lower(trim(rp.mapped_data->>'category')) <> 'none') AS has_category, (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, `+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 rp.created_at, rp.updated_at
%s%s WHERE %s %s%s WHERE %s
ORDER BY %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", "name", "category", "category_name", "category_unique_id",
"feed_name", "sync_changes", "feed_name", "sync_changes",
"has_name", "has_description", "has_category", "has_attributes", "has_name", "has_description", "has_category", "has_attributes",
"main_image",
"created_at", "updated_at", "created_at", "updated_at",
}) })
}, },
@@ -1113,6 +1141,7 @@ func (s *Service) ListProcessedProducts(ctx context.Context, companyID uuid.UUID
`+processedHasAttributesSQL+` AS has_attributes, `+processedHasAttributesSQL+` AS has_attributes,
`+processedHasProcessedAttributesSQL+` AS has_processed_attributes, `+processedHasProcessedAttributesSQL+` AS has_processed_attributes,
`+processedHasEprelSQL+` AS has_eprel, `+processedHasEprelSQL+` AS has_eprel,
`+listMainImageSQL("r.mapped_data")+` AS main_image,
p.created_at, p.updated_at p.created_at, p.updated_at
FROM processed_products p FROM processed_products p
LEFT JOIN raw_products r ON r.id = p.raw_product_id 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", "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_name", "has_processed_name", "has_description", "has_processed_description", "has_category", "has_attributes", "has_processed_attributes",
"has_eprel", "has_eprel",
"main_image",
"created_at", "updated_at", "created_at", "updated_at",
}) })
if err != nil { if err != nil {
@@ -8,6 +8,8 @@
} from "$lib/billing-display"; } from "$lib/billing-display";
import { i18n } from "$lib/i18n"; import { i18n } from "$lib/i18n";
import { FolderTree, ListTree, Package, Rss, Sparkles } from "@lucide/svelte"; import { FolderTree, ListTree, Package, Rss, Sparkles } from "@lucide/svelte";
import { planCapabilities } from "$lib/plan-capabilities.svelte";
import { featureKeyForHref } from "$lib/plan-capabilities";
let { let {
stats, stats,
@@ -89,7 +91,12 @@
icon: Rss, icon: Rss,
tour: "stats-feeds" 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);
}));
</script> </script>
<div <div
@@ -38,6 +38,7 @@
resolveOriginalName resolveOriginalName
} from "./types"; } from "./types";
import HtmlContent from "./HtmlContent.svelte"; import HtmlContent from "./HtmlContent.svelte";
import ProductImageGallery from "./ProductImageGallery.svelte";
import { looksLikeHtml } from "./html-content"; import { looksLikeHtml } from "./html-content";
import ContentLanguageSwitcher from "$lib/components/ContentLanguageSwitcher.svelte"; import ContentLanguageSwitcher from "$lib/components/ContentLanguageSwitcher.svelte";
import { DEFAULT_CONTENT_LANGUAGE, parseContentLanguage } from "$lib/content-languages"; import { DEFAULT_CONTENT_LANGUAGE, parseContentLanguage } from "$lib/content-languages";
@@ -895,33 +896,10 @@
</div> </div>
</section> </section>
{#if imageUrls.length > 0} <ProductImageGallery
<section class="space-y-3" data-testid="product-images"> images={imageUrls}
<h3 class="text-sm font-medium">{i18n.t("products.edit.images")}</h3> productName={product?.processed_name || product?.name || productId}
<div class="flex flex-wrap gap-3">
{#each imageUrls as src, i}
<a
href={src}
target="_blank"
rel="noopener noreferrer"
class="block overflow-hidden rounded-md border border-border bg-muted/30"
title={src}
>
<img
src={src}
alt={i18n.t("products.edit.imageAlt", {
index: i + 1,
name: product?.processed_name || product?.name || productId
})}
class="h-28 w-28 object-contain"
loading="lazy"
referrerpolicy="no-referrer"
/> />
</a>
{/each}
</div>
</section>
{/if}
</TabsContent> </TabsContent>
<TabsContent value="feed" class="mt-4 space-y-4"> <TabsContent value="feed" class="mt-4 space-y-4">
@@ -0,0 +1,263 @@
<script lang="ts">
import { ChevronLeft, ChevronRight, ExternalLink, ImageOff, X } from "@lucide/svelte";
import { i18n } from "$lib/i18n";
import { cn } from "$lib/utils";
import { activateFocusTrap, type FocusTrapHandle } from "$lib/a11y/focus-trap";
import { portal } from "$lib/actions/portal";
let {
images,
productName = ""
}: {
/** Image URLs in display order; the first is the main image. */
images: string[];
productName?: string;
} = $props();
/** Index of the image shown full-screen, or null when the lightbox is closed. */
let openIndex = $state<number | null>(null);
/** URLs that failed to load — shown as a placeholder tile instead of a torn icon. */
let broken = $state<Set<string>>(new Set());
let overlayEl = $state<HTMLDivElement | null>(null);
let trap: FocusTrapHandle | null = null;
const open = $derived(openIndex !== null);
const current = $derived(openIndex === null ? null : (images[openIndex] ?? null));
function altFor(index: number): string {
return i18n.t("products.edit.imageAlt", { index: index + 1, name: productName });
}
function markBroken(url: string) {
if (broken.has(url)) return;
broken = new Set([...broken, url]);
}
function show(index: number) {
if (images.length === 0) return;
// Wrap around so arrow keys never dead-end on the first / last image.
openIndex = (index + images.length) % images.length;
}
function close() {
openIndex = null;
}
function onKeydown(event: KeyboardEvent) {
if (openIndex === null) return;
if (event.key === "Escape") {
event.preventDefault();
close();
return;
}
if (event.key === "ArrowRight") {
event.preventDefault();
show(openIndex + 1);
return;
}
if (event.key === "ArrowLeft") {
event.preventDefault();
show(openIndex - 1);
}
}
// Lock page scroll and trap focus while the overlay covers the screen.
$effect(() => {
if (!open || typeof document === "undefined") return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previousOverflow;
};
});
$effect(() => {
if (!open || !overlayEl) {
trap?.deactivate();
trap = null;
return;
}
trap = activateFocusTrap(overlayEl);
return () => {
trap?.deactivate();
trap = null;
};
});
</script>
<svelte:window onkeydown={onKeydown} />
{#if images.length > 0}
<section class="space-y-3" data-testid="product-images">
<div class="flex items-baseline gap-2">
<h3 class="text-sm font-medium">{i18n.t("products.edit.images")}</h3>
<span class="text-xs text-muted-foreground">
{i18n.t("products.edit.imageCount", { count: images.length })}
</span>
</div>
<div class="flex flex-wrap gap-3">
{#each images as src, i (src)}
<button
type="button"
class="group relative block overflow-hidden rounded-md border border-border bg-muted/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
title={i18n.t("products.edit.openImage")}
aria-label={i18n.t("products.edit.openImageAt", { index: i + 1 })}
data-testid="product-image-thumb"
onclick={() => show(i)}
>
{#if broken.has(src)}
<span class="flex h-28 w-28 items-center justify-center">
<ImageOff class="h-5 w-5 text-muted-foreground/60" />
</span>
{:else}
<img
{src}
alt={altFor(i)}
class="h-28 w-28 object-contain transition-transform duration-200 group-hover:scale-105"
loading="lazy"
decoding="async"
referrerpolicy="no-referrer"
onerror={() => markBroken(src)}
/>
{/if}
</button>
{/each}
</div>
</section>
{/if}
{#if open && current}
<!-- Full-screen viewer. Sits above the edit panel; Escape / backdrop click closes. -->
<!--
Portalled to <body> and above the sticky app header (z-[110]) so a full-screen
image is genuinely full screen instead of sitting under the top bar.
-->
<div
bind:this={overlayEl}
use:portal
class="fixed inset-0 z-[120] flex flex-col bg-black/95"
role="dialog"
aria-modal="true"
aria-label={i18n.t("products.edit.galleryLabel", { name: productName })}
data-testid="product-image-lightbox"
>
<div class="flex items-center justify-between gap-3 px-4 py-3 text-white">
<p class="min-w-0 truncate text-sm" title={current}>
{i18n.t("products.edit.imageOf", {
index: (openIndex ?? 0) + 1,
total: images.length
})}
<span class="ml-2 text-white/60">{current}</span>
</p>
<div class="flex shrink-0 items-center gap-1">
<a
href={current}
target="_blank"
rel="noopener noreferrer"
class="rounded-md p-2 text-white/80 hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
aria-label={i18n.t("products.edit.openImageNewTab")}
title={i18n.t("products.edit.openImageNewTab")}
>
<ExternalLink class="h-5 w-5" />
</a>
<button
type="button"
class="rounded-md p-2 text-white/80 hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
aria-label={i18n.t("common.close")}
data-testid="product-image-lightbox-close"
onclick={close}
>
<X class="h-5 w-5" />
</button>
</div>
</div>
<div class="relative flex min-h-0 flex-1 items-center justify-center px-4 pb-4">
<!--
Backdrop sits behind the image as its own hit target, so clicking beside the
image closes while clicking the image itself does nothing (no stopPropagation
handler on a non-interactive element).
-->
<button
type="button"
class="absolute inset-0 z-0 cursor-default"
aria-label={i18n.t("common.close")}
tabindex="-1"
onclick={close}
></button>
{#if images.length > 1}
<button
type="button"
class="absolute left-2 z-10 rounded-full bg-white/10 p-3 text-white hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60 sm:left-6"
aria-label={i18n.t("products.edit.previousImage")}
data-testid="product-image-prev"
onclick={() => show((openIndex ?? 0) - 1)}
>
<ChevronLeft class="h-6 w-6" />
</button>
{/if}
{#if broken.has(current)}
<div class="z-[1] flex flex-col items-center gap-2 text-white/70">
<ImageOff class="h-10 w-10" />
<p class="text-sm">{i18n.t("products.edit.imageFailed")}</p>
</div>
{:else}
<img
src={current}
alt={altFor(openIndex ?? 0)}
class="z-[1] max-h-full max-w-full object-contain"
decoding="async"
referrerpolicy="no-referrer"
onerror={() => markBroken(current)}
/>
{/if}
{#if images.length > 1}
<button
type="button"
class="absolute right-2 z-10 rounded-full bg-white/10 p-3 text-white hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60 sm:right-6"
aria-label={i18n.t("products.edit.nextImage")}
data-testid="product-image-next"
onclick={() => show((openIndex ?? 0) + 1)}
>
<ChevronRight class="h-6 w-6" />
</button>
{/if}
</div>
{#if images.length > 1}
<div class="flex shrink-0 justify-center gap-2 overflow-x-auto px-4 pb-4">
{#each images as src, i (src)}
<button
type="button"
class={cn(
"h-14 w-14 shrink-0 overflow-hidden rounded border-2 bg-black/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60",
i === openIndex ? "border-white" : "border-transparent opacity-60 hover:opacity-100"
)}
aria-label={i18n.t("products.edit.openImageAt", { index: i + 1 })}
aria-current={i === openIndex ? "true" : undefined}
onclick={() => show(i)}
>
{#if broken.has(src)}
<span class="flex h-full w-full items-center justify-center">
<ImageOff class="h-4 w-4 text-white/50" />
</span>
{:else}
<img
{src}
alt={altFor(i)}
class="h-full w-full object-contain"
loading="lazy"
decoding="async"
referrerpolicy="no-referrer"
onerror={() => markBroken(src)}
/>
{/if}
</button>
{/each}
</div>
{/if}
</div>
{/if}
@@ -6,6 +6,7 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
import VirtualList from "$lib/components/VirtualList.svelte"; import VirtualList from "$lib/components/VirtualList.svelte";
import { cn } from "$lib/utils"; import { cn } from "$lib/utils";
import ProductStatusBadge from "./ProductStatusBadge.svelte"; import ProductStatusBadge from "./ProductStatusBadge.svelte";
import ProductThumb from "./ProductThumb.svelte";
import { import {
categoryDisplayName, categoryDisplayName,
enrichmentChipClass, enrichmentChipClass,
@@ -21,6 +22,7 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
productLeanIssueLabels, productLeanIssueLabels,
productLeanIssues, productLeanIssues,
productSku, productSku,
productThumbUrl,
type CategoryOption, type CategoryOption,
type EnrichmentPiece, type EnrichmentPiece,
type ProductRow type ProductRow
@@ -530,7 +532,13 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
/> />
</div> </div>
<div class="flex min-w-0 flex-1 flex-col p-3 md:min-w-[10rem]"> <div class="flex min-w-0 flex-1 items-start gap-3 p-3 md:min-w-[10rem]">
<ProductThumb
src={productThumbUrl(product)}
alt={productDisplayName(product, kind)}
class="mt-0.5"
/>
<div class="flex min-w-0 flex-1 flex-col">
<div class="truncate font-mono text-xs text-muted-foreground">{productSku(product)}</div> <div class="truncate font-mono text-xs text-muted-foreground">{productSku(product)}</div>
<div class="min-w-0 font-medium"> <div class="min-w-0 font-medium">
{#if editingId !== null && String(editingId) === String(product.id)} {#if editingId !== null && String(editingId) === String(product.id)}
@@ -640,6 +648,7 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
<ProductStatusBadge status={product.status ?? product.processing_status} /> <ProductStatusBadge status={product.status ?? product.processing_status} />
</div> </div>
</div> </div>
</div>
<div class="hidden w-[128px] shrink-0 items-center p-3 sm:flex sm:w-[140px]"> <div class="hidden w-[128px] shrink-0 items-center p-3 sm:flex sm:w-[140px]">
{#if Number.isFinite(qScore)} {#if Number.isFinite(qScore)}
@@ -0,0 +1,57 @@
<script lang="ts">
import { ImageOff } from "@lucide/svelte";
import { i18n } from "$lib/i18n";
import { cn } from "$lib/utils";
let {
src = null,
alt = "",
size = "md",
class: className = ""
}: {
/** First image URL for the product, or null when the feed carries none. */
src?: string | null;
alt?: string;
size?: "sm" | "md";
class?: string;
} = $props();
/** Broken supplier URLs are common — fall back to the placeholder instead of a torn icon. */
let failed = $state(false);
// Reset when the row is recycled onto a different product (virtual list reuse).
$effect(() => {
void src;
failed = false;
});
const box = $derived(size === "sm" ? "h-10 w-10" : "h-14 w-14");
const showImage = $derived(Boolean(src) && !failed);
</script>
<div
class={cn(
"flex shrink-0 items-center justify-center overflow-hidden rounded-md border border-border bg-muted/40",
box,
className
)}
data-testid="product-thumb"
data-state={showImage ? "image" : "placeholder"}
>
{#if showImage}
<img
src={src ?? ""}
{alt}
class="h-full w-full object-contain"
loading="lazy"
decoding="async"
referrerpolicy="no-referrer"
onerror={() => (failed = true)}
/>
{:else}
<ImageOff
class="h-4 w-4 text-muted-foreground/60"
aria-label={i18n.t("products.table.noImage")}
/>
{/if}
</div>
@@ -7,7 +7,8 @@ export type {
export { export {
coerceImageUrl, coerceImageUrl,
coerceImageUrlList, coerceImageUrlList,
productImageUrls productImageUrls,
productThumbUrl
} from "../../product-images"; } from "../../product-images";
export { export {
hasExplicitProductTab, hasExplicitProductTab,
@@ -75,6 +76,8 @@ export type ProductRow = {
attributes?: unknown; attributes?: unknown;
processed_attributes?: unknown; processed_attributes?: unknown;
mapped_data?: unknown; mapped_data?: unknown;
/** Precomputed first image URL on lean list rows (see catalog.listMainImageSQL). */
main_image?: string | null;
has_name?: boolean | null; has_name?: boolean | null;
has_processed_name?: boolean | null; has_processed_name?: boolean | null;
has_description?: boolean | null; has_description?: boolean | null;
@@ -6,12 +6,18 @@
import { sectionLabel } from "$lib/plan-feature-catalog"; import { sectionLabel } from "$lib/plan-feature-catalog";
import { Badge, Button, Checkbox, Dialog, Input, Spinner } from "$lib/components/ui"; import { Badge, Button, Checkbox, Dialog, Input, Spinner } from "$lib/components/ui";
import Alert from "$lib/components/Alert.svelte"; import Alert from "$lib/components/Alert.svelte";
import { ChevronDown, ChevronRight, Check } from "@lucide/svelte";
import { import {
CUSTOM_ROLE_ID,
PERMISSION_ROLES,
catalogKeys, catalogKeys,
denyAll, denyAll,
detectRole,
filterCatalog, filterCatalog,
isDenied, isDenied,
permissionSummary, permissionSummary,
roleById,
rolePresetDenied,
samePermissions, samePermissions,
setPermissionAllowed, setPermissionAllowed,
type MemberPermissionsView, type MemberPermissionsView,
@@ -40,11 +46,28 @@
let loadError = $state<string | null>(null); let loadError = $state<string | null>(null);
let search = $state(""); let search = $state("");
let isOwnerMember = $state(false); let isOwnerMember = $state(false);
/** Raw per-key checkboxes stay collapsed — the role picker is the primary control. */
let advancedOpen = $state(false);
const allKeys = $derived(catalogKeys(catalog)); const allKeys = $derived(catalogKeys(catalog));
const summary = $derived(permissionSummary(catalog, denied)); const summary = $derived(permissionSummary(catalog, denied));
const dirty = $derived(!samePermissions(denied, savedDenied)); const dirty = $derived(!samePermissions(denied, savedDenied));
const readOnly = $derived(!canEdit || isOwnerMember); const readOnly = $derived(!canEdit || isOwnerMember);
/** Which preset the current selection matches, or "custom" after fine-tuning. */
const activeRole = $derived(detectRole(catalog, denied));
function roleLabel(id: string): string {
return i18n.t(`settings.permissions.role.${id}`);
}
function roleDescription(id: string): string {
return i18n.t(`settings.permissions.role.${id}.desc`);
}
function applyRole(id: string) {
if (readOnly || id === CUSTOM_ROLE_ID) return;
denied = rolePresetDenied(catalog, roleById(id));
}
function labelFor(key: string): string { function labelFor(key: string): string {
const translated = i18n.t(`plan.feature.${key}`); const translated = i18n.t(`plan.feature.${key}`);
@@ -62,6 +85,7 @@
loading = true; loading = true;
loadError = null; loadError = null;
search = ""; search = "";
advancedOpen = false;
void (async () => { void (async () => {
try { try {
const [catalogPayload, view] = await Promise.all([ const [catalogPayload, view] = await Promise.all([
@@ -147,15 +171,74 @@
<Alert tone="info" message={i18n.t("settings.permissions.ownerOnly")} /> <Alert tone="info" message={i18n.t("settings.permissions.ownerOnly")} />
{/if} {/if}
<div class="flex flex-wrap items-center justify-between gap-3"> <fieldset class="space-y-2" disabled={readOnly}>
<p class="text-sm text-muted-foreground" data-testid="permissions-summary"> <legend class="text-sm font-medium">{i18n.t("settings.permissions.roleHeading")}</legend>
<p class="text-xs text-muted-foreground">
{i18n.t("settings.permissions.roleHint")}
</p>
<div class="grid gap-2 sm:grid-cols-2" role="radiogroup" aria-label={i18n.t("settings.permissions.roleHeading")}>
{#each PERMISSION_ROLES as role (role.id)}
{@const selected = activeRole === role.id}
<button
type="button"
role="radio"
aria-checked={selected}
disabled={readOnly}
data-testid={`perm-role-${role.id}`}
class="flex items-start gap-2 rounded-md border px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60 {selected
? 'border-primary bg-primary/5'
: 'border-border hover:bg-muted/40'}"
onclick={() => applyRole(role.id)}
>
<span
class="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border {selected
? 'border-primary bg-primary text-primary-foreground'
: 'border-muted-foreground/40'}"
aria-hidden="true"
>
{#if selected}<Check class="h-3 w-3" />{/if}
</span>
<span class="min-w-0">
<span class="block text-sm font-medium">{roleLabel(role.id)}</span>
<span class="block text-xs text-muted-foreground">{roleDescription(role.id)}</span>
</span>
</button>
{/each}
</div>
{#if activeRole === CUSTOM_ROLE_ID}
<p class="rounded-md bg-muted/50 px-3 py-2 text-xs text-muted-foreground" data-testid="perm-role-custom">
<span class="font-medium text-foreground">{roleLabel(CUSTOM_ROLE_ID)}</span>
&mdash; {roleDescription(CUSTOM_ROLE_ID)}
</p>
{/if}
</fieldset>
<div class="border-t border-border pt-3">
<button
type="button"
class="flex w-full items-center gap-2 text-left text-sm font-medium hover:text-primary"
aria-expanded={advancedOpen}
data-testid="permissions-advanced-toggle"
onclick={() => (advancedOpen = !advancedOpen)}
>
{#if advancedOpen}
<ChevronDown class="h-4 w-4" />
{:else}
<ChevronRight class="h-4 w-4" />
{/if}
<span>{i18n.t("settings.permissions.advanced")}</span>
<span class="ml-auto text-xs font-normal text-muted-foreground" data-testid="permissions-summary">
{i18n.t("settings.permissions.summary", { {i18n.t("settings.permissions.summary", {
allowed: summary.allowed, allowed: summary.allowed,
total: summary.total total: summary.total
})} })}
</p> </span>
</button>
{#if advancedOpen}
<div class="mt-3 space-y-3">
{#if !readOnly} {#if !readOnly}
<div class="flex gap-2"> <div class="flex flex-wrap gap-2">
<Button variant="outline" size="sm" data-testid="permissions-allow-all" onclick={allowEverything}> <Button variant="outline" size="sm" data-testid="permissions-allow-all" onclick={allowEverything}>
{i18n.t("settings.permissions.allowAll")} {i18n.t("settings.permissions.allowAll")}
</Button> </Button>
@@ -164,7 +247,6 @@
</Button> </Button>
</div> </div>
{/if} {/if}
</div>
<Input <Input
type="search" type="search"
@@ -173,7 +255,7 @@
aria-label={i18n.t("settings.permissions.searchPlaceholder")} aria-label={i18n.t("settings.permissions.searchPlaceholder")}
/> />
<div class="max-h-[24rem] space-y-5 overflow-y-auto pr-1"> <div class="max-h-[20rem] space-y-5 overflow-y-auto pr-1">
{#if visibleSections.length === 0} {#if visibleSections.length === 0}
<p class="py-8 text-center text-sm text-muted-foreground"> <p class="py-8 text-center text-sm text-muted-foreground">
{i18n.t("settings.permissions.noMatches")} {i18n.t("settings.permissions.noMatches")}
@@ -218,6 +300,9 @@
</section> </section>
{/each} {/each}
</div> </div>
</div>
{/if}
</div>
{/if} {/if}
</div> </div>
+28 -1
View File
@@ -5678,5 +5678,32 @@ export const de: MessageDict = {
"settings.permissions.loadFailed": "Berechtigungen konnten nicht geladen werden", "settings.permissions.loadFailed": "Berechtigungen konnten nicht geladen werden",
"settings.permissions.restrictedBadge": "Eingeschränkt", "settings.permissions.restrictedBadge": "Eingeschränkt",
"settings.permissions.restrictedHint": "{count} Bereiche deaktiviert", "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"
}; };
+28 -1
View File
@@ -5764,5 +5764,32 @@ export const en: MessageDict = {
"settings.permissions.loadFailed": "Could not load permissions", "settings.permissions.loadFailed": "Could not load permissions",
"settings.permissions.restrictedBadge": "Restricted", "settings.permissions.restrictedBadge": "Restricted",
"settings.permissions.restrictedHint": "{count} areas turned off", "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"
}; };
+28 -1
View File
@@ -5687,5 +5687,32 @@ export const es: MessageDict = {
"settings.permissions.loadFailed": "No se pudieron cargar los permisos", "settings.permissions.loadFailed": "No se pudieron cargar los permisos",
"settings.permissions.restrictedBadge": "Restringido", "settings.permissions.restrictedBadge": "Restringido",
"settings.permissions.restrictedHint": "{count} secciones desactivadas", "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"
}; };
+28 -1
View File
@@ -5687,5 +5687,32 @@ export const fr: MessageDict = {
"settings.permissions.loadFailed": "Impossible de charger les autorisations", "settings.permissions.loadFailed": "Impossible de charger les autorisations",
"settings.permissions.restrictedBadge": "Restreint", "settings.permissions.restrictedBadge": "Restreint",
"settings.permissions.restrictedHint": "{count} sections désactivées", "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 dimage",
"products.edit.imageCount": "{count} images",
"products.edit.openImage": "Ouvrir en plein écran",
"products.edit.openImageAt": "Ouvrir limage {index} en plein écran",
"products.edit.openImageNewTab": "Ouvrir loriginal 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 dautre.",
"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"
}; };
+28 -1
View File
@@ -5687,5 +5687,32 @@ export const it: MessageDict = {
"settings.permissions.loadFailed": "Impossibile caricare le autorizzazioni", "settings.permissions.loadFailed": "Impossibile caricare le autorizzazioni",
"settings.permissions.restrictedBadge": "Limitato", "settings.permissions.restrictedBadge": "Limitato",
"settings.permissions.restrictedHint": "{count} sezioni disattivate", "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 limmagine {index} a schermo intero",
"products.edit.openImageNewTab": "Apri loriginale 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. Nientaltro.",
"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"
}; };
+28 -1
View File
@@ -5687,5 +5687,32 @@ export const ja: MessageDict = {
"settings.permissions.loadFailed": "権限を読み込めませんでした", "settings.permissions.loadFailed": "権限を読み込めませんでした",
"settings.permissions.restrictedBadge": "制限あり", "settings.permissions.restrictedBadge": "制限あり",
"settings.permissions.restrictedHint": "{count} 件のエリアを無効化", "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": "エリアごとに細かく調整"
}; };
+28 -1
View File
@@ -5687,5 +5687,32 @@ export const nl: MessageDict = {
"settings.permissions.loadFailed": "Rechten konden niet worden geladen", "settings.permissions.loadFailed": "Rechten konden niet worden geladen",
"settings.permissions.restrictedBadge": "Beperkt", "settings.permissions.restrictedBadge": "Beperkt",
"settings.permissions.restrictedHint": "{count} onderdelen uitgeschakeld", "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"
}; };
+28 -1
View File
@@ -5687,5 +5687,32 @@ export const pl: MessageDict = {
"settings.permissions.loadFailed": "Nie udało się wczytać uprawnień", "settings.permissions.loadFailed": "Nie udało się wczytać uprawnień",
"settings.permissions.restrictedBadge": "Ograniczony", "settings.permissions.restrictedBadge": "Ograniczony",
"settings.permissions.restrictedHint": "Wyłączonych sekcji: {count}", "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"
}; };
+28 -1
View File
@@ -5687,5 +5687,32 @@ export const pt: MessageDict = {
"settings.permissions.loadFailed": "Não foi possível carregar as permissões", "settings.permissions.loadFailed": "Não foi possível carregar as permissões",
"settings.permissions.restrictedBadge": "Restrito", "settings.permissions.restrictedBadge": "Restrito",
"settings.permissions.restrictedHint": "{count} áreas desativadas", "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"
}; };
+127
View File
@@ -9,6 +9,11 @@ import {
permissionSummary, permissionSummary,
samePermissions, samePermissions,
setPermissionAllowed, setPermissionAllowed,
CUSTOM_ROLE_ID,
PERMISSION_ROLES,
detectRole,
roleById,
rolePresetDenied,
type PermissionCatalog type PermissionCatalog
} from "./member-permissions.ts"; } 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");
});
});
+111 -4
View File
@@ -91,7 +91,7 @@ export function setPermissionAllowed(
if (!allowed) { if (!allowed) {
for (const descendant of descendantsOf(key, allKeys)) next.delete(descendant); for (const descendant of descendantsOf(key, allKeys)) next.delete(descendant);
next.add(key); next.add(key);
return normalize(next, allKeys); return normalizeDenied(next, allKeys);
} }
next.delete(key); next.delete(key);
@@ -104,11 +104,11 @@ export function setPermissionAllowed(
if (!openPath.has(sibling)) next.add(sibling); 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. */ /** Drop keys outside the catalog and denials already implied by a denied ancestor. */
function normalize(denied: Set<string>, allKeys: string[]): string[] { export function normalizeDenied(denied: Set<string>, allKeys: string[]): string[] {
const known = new Set(allKeys); const known = new Set(allKeys);
const out: string[] = []; const out: string[] = [];
for (const key of denied) { for (const key of denied) {
@@ -122,7 +122,7 @@ function normalize(denied: Set<string>, allKeys: string[]): string[] {
/** Deny every grantable key (the always-on shell keys are not in the catalog). */ /** Deny every grantable key (the always-on shell keys are not in the catalog). */
export function denyAll(catalog: PermissionCatalog | null | undefined): string[] { export function denyAll(catalog: PermissionCatalog | null | undefined): string[] {
const all = catalogKeys(catalog); 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. */ /** Allowed / total counts for the summary line, ignoring keys the plan already denies. */
@@ -170,3 +170,110 @@ export function filterCatalog(
} }
return out; 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<string>();
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;
}
+15
View File
@@ -139,3 +139,18 @@ export function productImageUrls(product: ProductImageSource): string[] {
} }
return out; 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;
}
+13 -1
View File
@@ -335,6 +335,12 @@
const canMonitorJobs = $derived(planCapabilities.can("processing.monitor")); const canMonitorJobs = $derived(planCapabilities.can("processing.monitor"));
const canExportFeeds = $derived(planCapabilities.can("feeds.export_feeds")); 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({ const planFlags = $derived({
name: typeof planInfo?.name === "string" ? planInfo.name : "", name: typeof planInfo?.name === "string" ? planInfo.name : "",
is_legacy: typeof planInfo?.is_legacy === "boolean" ? planInfo.is_legacy : undefined, is_legacy: typeof planInfo?.is_legacy === "boolean" ? planInfo.is_legacy : undefined,
@@ -419,7 +425,9 @@
done: processedTotal > 0, done: processedTotal > 0,
tour: "workflow-process" tour: "workflow-process"
} }
]); ].filter((step) =>
step.id === "process" ? canBrowseProducts : canBrowseFeeds
));
</script> </script>
{#if loading} {#if loading}
@@ -804,6 +812,7 @@
<p class="text-xs text-text-muted">{i18n.t("dashboard.quickLinksHint")}</p> <p class="text-xs text-text-muted">{i18n.t("dashboard.quickLinksHint")}</p>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
{#if canBrowseFeeds}
<a <a
href="/feeds" href="/feeds"
class="group flex items-center gap-3 rounded-xl border border-border bg-surface px-3 py-2.5 text-text shadow-sm transition hover:border-ring/40 hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" class="group flex items-center gap-3 rounded-xl border border-border bg-surface px-3 py-2.5 text-text shadow-sm transition hover:border-ring/40 hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@@ -818,6 +827,8 @@
</div> </div>
<ArrowRight class="h-4 w-4 shrink-0 text-text-muted transition group-hover:translate-x-0.5" /> <ArrowRight class="h-4 w-4 shrink-0 text-text-muted transition group-hover:translate-x-0.5" />
</a> </a>
{/if}
{#if canBrowseProducts}
<a <a
href="/products" href="/products"
class="group flex items-center gap-3 rounded-xl border border-border bg-surface px-3 py-2.5 text-text shadow-sm transition hover:border-ring/40 hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" class="group flex items-center gap-3 rounded-xl border border-border bg-surface px-3 py-2.5 text-text shadow-sm transition hover:border-ring/40 hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@@ -833,6 +844,7 @@
</div> </div>
<ArrowRight class="h-4 w-4 shrink-0 text-text-muted transition group-hover:translate-x-0.5" /> <ArrowRight class="h-4 w-4 shrink-0 text-text-muted transition group-hover:translate-x-0.5" />
</a> </a>
{/if}
{#if canMonitorJobs} {#if canMonitorJobs}
<a <a
href="/processing" href="/processing"