fix
This commit is contained in:
@@ -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>
|
||||||
|
— {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>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 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"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 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"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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": "エリアごとに細かく調整"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user