This commit is contained in:
2026-08-17 00:39:25 +02:00
parent 92f046b542
commit 93dc70123c
54 changed files with 25528 additions and 20666 deletions
+29 -2
View File
@@ -270,6 +270,8 @@ export type SyncA1Result = {
dump_mapped_updated?: number;
dump_processed_updated?: number;
dump_status?: string;
wp_categories_path?: string;
wp_categories_entries?: number;
};
export type SyncA1Response = {
@@ -283,11 +285,36 @@ export type FixCatalogResult = SyncA1Result;
/** @deprecated Use SyncA1Response */
export type FixCatalogResponse = SyncA1Response;
/** Sync A1: dump category backfill (when dump on API host) + Fix hygiene. No mass reprocess. */
/** Sync A1: optional wp_product_categories.sql upload + dump category backfill + Fix hygiene. */
export async function syncAdminCompanyA1(
companyId: string,
opts?: { backfillCategories?: boolean; reprocessSampleLimit?: number; skipDumpBackfill?: boolean }
opts?: {
backfillCategories?: boolean;
reprocessSampleLimit?: number;
skipDumpBackfill?: boolean;
wpProductCategoriesFile?: File | null;
}
): Promise<SyncA1Response> {
const file = opts?.wpProductCategoriesFile ?? null;
if (file) {
const body = new FormData();
body.append("confirm", "true");
if (opts?.backfillCategories === false) {
body.append("backfill_categories", "false");
}
if (typeof opts?.reprocessSampleLimit === "number") {
body.append("reprocess_sample_limit", String(opts.reprocessSampleLimit));
}
if (opts?.skipDumpBackfill) {
body.append("skip_dump_backfill", "true");
}
body.append("wp_product_categories", file, file.name || "wp_product_categories.sql");
return api<SyncA1Response>(ADMIN_SYNC_A1_PATH(companyId), {
method: "POST",
body
});
}
const body: {
confirm: true;
backfill_categories?: boolean;
@@ -0,0 +1,203 @@
/**
* Parse / compose category enhance USER prompts that use role section markers
* (mirrors apps/api/internal/aiprompts/roles.go Section* constants).
*
* Storage remains a single language string with --- Title --- markers so
* enhance one-shot JSON parse (name, description, meta_*, attrs) keeps working.
*/
export type EnhancePromptSectionId = "title" | "description" | "meta" | "attributes";
export const ENHANCE_PROMPT_SECTIONS: readonly EnhancePromptSectionId[] = [
"title",
"description",
"meta",
"attributes"
] as const;
/** Markers must stay in sync with aiprompts.Section* constants. */
export const SECTION_MARKERS: Record<
EnhancePromptSectionId,
{ start: string; end: string }
> = {
title: { start: "--- Title ---", end: "--- End Title ---" },
description: { start: "--- Description ---", end: "--- End Description ---" },
meta: { start: "--- Meta ---", end: "--- End Meta ---" },
attributes: { start: "--- Attributes ---", end: "--- End Attributes ---" }
};
/** Canonical shared intro (same idea as CategoryEnhanceUserTemplate preamble). */
export const DEFAULT_ENHANCE_PREAMBLE =
'Your reply is parsed as JSON {"name":"string","description":"string","meta_title":"string","meta_description":"string","attrs":{}} only (system schema). Write all string fields in {{language}} (do not hardcode a language).';
/** Minimal role bodies used when a section is empty on compose (keeps markers valid). */
export const DEFAULT_SECTION_BODIES: Record<EnhancePromptSectionId, string> = {
title:
'Role: title. Build JSON "name": short retail title from Title formula + Attrs — never brand-only. Include product type and full model when evidence exists; follow any Title formula constraints that follow; use Attrs.\nName: {{name}}',
description:
'Role: description. Build JSON "description": product body HTML only (not SEO meta). When a Description formula follows, emit ONE HTML string covering each section in order; otherwise prefer 1-3 factual paragraphs as ONE string with limited HTML (<h2><p><ul><li>).\nDescription: {{description}}',
meta: 'Role: meta. Build JSON "meta_title" and "meta_description" as plain SEO text (never HTML). meta_title: 50-60 chars; meta_description: 120-155 chars; follow any SEO meta formula that follows; never copy the full description HTML into meta_description.',
attributes:
'Role: attributes. Build JSON "attrs" as an object of attribute_key → value strings. Prefer Allowed attribute keys / Title formula attr slots that follow; remap near-miss labels onto those keys; fill missing keys only from Name/Description/Category/Attrs evidence; never invent specs; omit unknown keys; never invent dimensions.\nCategory: {{category}}\nAttrs: {{attrs}}'
};
export type SectionSchemaHint = {
/** JSON keys this section is responsible for in the one-shot enhance reply. */
jsonKeys: string[];
/** Short example fragment for the UI. */
example: string;
/** Vars that are most useful in this section. */
suggestedVars: readonly string[];
};
export const SECTION_SCHEMA_HINTS: Record<EnhancePromptSectionId, SectionSchemaHint> = {
title: {
jsonKeys: ["name"],
example: '{"name":"Acme Widget Pro"}',
suggestedVars: ["name", "attrs", "brand_voice", "language"]
},
description: {
jsonKeys: ["description"],
example: '{"description":"<p>…</p>"}',
suggestedVars: ["description", "name", "category", "attrs", "brand_voice", "language"]
},
meta: {
jsonKeys: ["meta_title", "meta_description"],
example: '{"meta_title":"…","meta_description":"…"}',
suggestedVars: ["name", "description", "category", "brand_voice", "language"]
},
attributes: {
jsonKeys: ["attrs"],
example: '{"attrs":{"brand":"Acme","product_model":"Widget Pro"}}',
suggestedVars: ["attrs", "category", "name", "description", "gtin", "language"]
}
};
export type ParsedEnhancePrompt = {
/** True when all four role markers were present. */
hasRoleSections: boolean;
/** Text above the first --- Section --- (JSON schema intro). */
preamble: string;
sections: Record<EnhancePromptSectionId, string>;
};
function normalizeNewlines(s: string): string {
return s.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
}
function findMarkerIndex(haystackLower: string, marker: string): number {
return haystackLower.indexOf(marker.toLowerCase());
}
/** Extract body between start/end markers (case-insensitive markers, body preserved). */
export function extractSectionBody(prompt: string, id: EnhancePromptSectionId): string | null {
const raw = normalizeNewlines(prompt);
const lower = raw.toLowerCase();
const { start, end } = SECTION_MARKERS[id];
const startIdx = findMarkerIndex(lower, start);
if (startIdx < 0) return null;
const bodyStart = startIdx + start.length;
const endIdx = findMarkerIndex(lower.slice(bodyStart), end);
if (endIdx < 0) {
return raw.slice(bodyStart).replace(/^\n+/, "").trimEnd();
}
return raw
.slice(bodyStart, bodyStart + endIdx)
.replace(/^\n+/, "")
.replace(/\n+$/, "");
}
export function hasEnhanceRoleSections(prompt: string): boolean {
const lower = normalizeNewlines(prompt).toLowerCase();
return (
lower.includes(SECTION_MARKERS.title.start.toLowerCase()) &&
lower.includes(SECTION_MARKERS.description.start.toLowerCase()) &&
lower.includes(SECTION_MARKERS.meta.start.toLowerCase()) &&
lower.includes(SECTION_MARKERS.attributes.start.toLowerCase())
);
}
function extractPreamble(prompt: string): string {
const raw = normalizeNewlines(prompt);
const lower = raw.toLowerCase();
let first = -1;
for (const id of ENHANCE_PROMPT_SECTIONS) {
const idx = findMarkerIndex(lower, SECTION_MARKERS[id].start);
if (idx >= 0 && (first < 0 || idx < first)) first = idx;
}
if (first <= 0) return "";
return raw.slice(0, first).trim();
}
/**
* Decompose a stored categories.prompt string into preamble + per-role bodies.
* Unstructured blobs land in description so content is not lost on first save.
*/
export function parseEnhancePrompt(prompt: string): ParsedEnhancePrompt {
const raw = normalizeNewlines(prompt).trim();
if (!raw) {
return {
hasRoleSections: false,
preamble: DEFAULT_ENHANCE_PREAMBLE,
sections: {
title: "",
description: "",
meta: "",
attributes: ""
}
};
}
const structured = hasEnhanceRoleSections(raw);
if (!structured) {
return {
hasRoleSections: false,
preamble: DEFAULT_ENHANCE_PREAMBLE,
sections: {
title: "",
description: raw,
meta: "",
attributes: ""
}
};
}
const sections = {} as Record<EnhancePromptSectionId, string>;
for (const id of ENHANCE_PROMPT_SECTIONS) {
sections[id] = extractSectionBody(raw, id) ?? "";
}
const preamble = extractPreamble(raw) || DEFAULT_ENHANCE_PREAMBLE;
return { hasRoleSections: true, preamble, sections };
}
/**
* Compose preamble + section bodies back into the stored enhance USER template shape.
* Empty section bodies fall back to DEFAULT_SECTION_BODIES so markers stay valid.
*/
export function composeEnhancePrompt(
preamble: string,
sections: Partial<Record<EnhancePromptSectionId, string>>
): string {
const intro = (preamble.trim() || DEFAULT_ENHANCE_PREAMBLE).trim();
const parts: string[] = [intro, ""];
for (const id of ENHANCE_PROMPT_SECTIONS) {
const { start, end } = SECTION_MARKERS[id];
const body = (sections[id] ?? "").trim() || DEFAULT_SECTION_BODIES[id];
parts.push(start, body, end, "");
}
return parts.join("\n").trim();
}
/** True when every section (and optional preamble) is empty / default-cleared. */
export function isEnhancePromptEmpty(
preamble: string,
sections: Partial<Record<EnhancePromptSectionId, string>>
): boolean {
const hasCustomPreamble =
preamble.trim() !== "" && preamble.trim() !== DEFAULT_ENHANCE_PREAMBLE.trim();
if (hasCustomPreamble) return false;
return ENHANCE_PROMPT_SECTIONS.every((id) => !(sections[id] ?? "").trim());
}
@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
composeEnhancePrompt,
DEFAULT_ENHANCE_PREAMBLE,
hasEnhanceRoleSections,
isEnhancePromptEmpty,
parseEnhancePrompt,
SECTION_MARKERS
} from "./categories/prompt-sections.ts";
const sample = [
DEFAULT_ENHANCE_PREAMBLE,
"",
SECTION_MARKERS.title.start,
"Title body with {{name}}",
SECTION_MARKERS.title.end,
"",
SECTION_MARKERS.description.start,
"Desc body with {{description}}",
SECTION_MARKERS.description.end,
"",
SECTION_MARKERS.meta.start,
"Meta body",
SECTION_MARKERS.meta.end,
"",
SECTION_MARKERS.attributes.start,
"Attrs body {{attrs}}",
SECTION_MARKERS.attributes.end
].join("\n");
describe("category prompt sections", () => {
it("detects role section markers", () => {
assert.equal(hasEnhanceRoleSections(sample), true);
assert.equal(hasEnhanceRoleSections("plain marketing blob"), false);
});
it("parses and round-trips structured prompts", () => {
const parsed = parseEnhancePrompt(sample);
assert.equal(parsed.hasRoleSections, true);
assert.match(parsed.preamble, /meta_title/);
assert.equal(parsed.sections.title, "Title body with {{name}}");
assert.equal(parsed.sections.description, "Desc body with {{description}}");
assert.equal(parsed.sections.meta, "Meta body");
assert.equal(parsed.sections.attributes, "Attrs body {{attrs}}");
const again = composeEnhancePrompt(parsed.preamble, parsed.sections);
assert.equal(hasEnhanceRoleSections(again), true);
const reparsed = parseEnhancePrompt(again);
assert.equal(reparsed.sections.title, parsed.sections.title);
assert.equal(reparsed.sections.attributes, parsed.sections.attributes);
});
it("keeps unstructured prompts in description", () => {
const legacy = "Ustvari nov opis… {{description}}";
const parsed = parseEnhancePrompt(legacy);
assert.equal(parsed.hasRoleSections, false);
assert.equal(parsed.sections.description, legacy);
assert.equal(parsed.sections.title, "");
});
it("compose fills empty sections with defaults so markers remain", () => {
const out = composeEnhancePrompt(DEFAULT_ENHANCE_PREAMBLE, {
title: "Custom title rules",
description: "",
meta: "",
attributes: ""
});
assert.equal(hasEnhanceRoleSections(out), true);
assert.match(out, /Custom title rules/);
assert.match(out, /Role: description/);
assert.match(out, /\{\{attrs\}\}/);
});
it("isEnhancePromptEmpty ignores default preamble", () => {
assert.equal(isEnhancePromptEmpty(DEFAULT_ENHANCE_PREAMBLE, {}), true);
assert.equal(
isEnhancePromptEmpty(DEFAULT_ENHANCE_PREAMBLE, { title: "x" }),
false
);
});
});
File diff suppressed because it is too large Load Diff
+40 -2
View File
@@ -211,6 +211,13 @@ export const en: MessageDict = {
"settings.walletRemainingHint": "remaining",
"settings.role.member": "Member",
"settings.role.admin": "Admin",
"settings.cannotRemoveOwner": "Transfer ownership before removing the company owner.",
"settings.transferFailed": "Could not transfer ownership",
"settings.ownershipTransferred": "{email} is now the company owner.",
"settings.transferOwnershipConfirm": "Make {email} the company owner? They will handle billing for this company.",
"settings.transferOwnership": "Transfer ownership",
"settings.ownerBadge": "Owner",
"settings.role.owner": "Owner",
"settings.teamHeading": "Team Members",
"settings.inviteUser": "Invite user",
"settings.teamAdminOnly": "Only company admins can invite, promote, demote, or remove teammates.",
@@ -820,6 +827,10 @@ export const en: MessageDict = {
"admin.users.reissueInvite": "Re-issue invite",
"admin.users.setLocalPassword": "Set password",
"admin.users.setPasswordTitle": "Set password",
"admin.users.copyInviteLink": "Copy link",
"admin.users.inviteLinkLabel": "One-time set-password / accept invite link",
"admin.users.inviteLinkCopied": "Set-password link copied.",
"admin.users.inviteLinkReady": "Copy the set-password link below and share it securely.",
"admin.users.setPasswordDesc": "Force-set a login password for this user (works for legacy or fake emails that cannot receive invites).",
"admin.users.newPassword": "New password",
"admin.users.setPasswordSubmit": "Set password",
@@ -845,11 +856,14 @@ export const en: MessageDict = {
"admin.users.syncA1": "Sync A1",
"admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
"admin.users.syncA1Title": "Sync A1 catalog",
"admin.users.syncA1Desc": "Backfills mapped categories from the MySQL dump when present on the API host, then runs catalog hygiene (prompts, weak hashes, bidirectional category backfill, attribute sanitize). Does not wipe or reimport the full catalog.",
"admin.users.syncA1Desc": "Uploads wp_product_categories.sql to force-apply Title/Description/Meta/Attributes category prompts, optionally backfills mapped categories from a MySQL product dump on the API host, then runs catalog hygiene. Does not wipe or reimport the full catalog.",
"admin.users.syncA1Company": "Company: {name}",
"admin.users.syncA1Warning": "Requires descrybe_new.sql on the API server (scripts/seed/ or SEED_A1_MYSQL_DUMP) for dump category coverage. Without a dump, only DB hygiene runs. No mass reprocess.",
"admin.users.syncA1Warning": "Upload wp_product_categories.sql below (primary). Auto-detect on the API host is a fallback when no file is chosen. Optional: place descrybe_new.sql on the API server for product-dump category coverage. No mass reprocess.",
"admin.users.syncA1Cancel": "Cancel",
"admin.users.syncA1Confirm": "Sync A1",
"admin.users.syncA1WpUpload": "wp_product_categories.sql",
"admin.users.syncA1WpUploadHint": "Choose the WordPress category prompts dump. Max 16 MiB. When uploaded, Sync A1 force-applies sectioned prompts for title and description enhance.",
"admin.users.syncA1WpUploadClear": "Clear file",
"admin.users.noUsers": "No users match this filter.",
"admin.users.noCompanies": "No companies match this filter.",
"admin.users.assignRoleTitle": "Assign staff role",
@@ -4422,6 +4436,30 @@ export const en: MessageDict = {
"categories.usingCompanyDefaultForLang": "· empty for this language — company / built-in default applies",
"categories.aiPromptSavedLang": "AI prompt saved for {lang}.",
"categories.aiPromptClearedLang": "AI prompt cleared for {lang} (company default will apply).",
"categories.promptSection.navLabel": "Prompt sections",
"categories.promptSection.navHeading": "Sections",
"categories.promptSection.navHint": "Edit one part of the enhance reply at a time.",
"categories.promptSection.title": "Title",
"categories.promptSection.titleHelp": "Instructions for the product title (JSON name).",
"categories.promptSection.titleFormulaNote": "Title formula slots (type + brand + model) are appended automatically when set on this category.",
"categories.promptSection.description": "Description",
"categories.promptSection.descriptionHelp": "Instructions for the HTML product body (JSON description - not SEO meta).",
"categories.promptSection.descriptionFormulaNote": "Description formula sections are appended at render time when configured.",
"categories.promptSection.meta": "Meta",
"categories.promptSection.metaHelp": "Instructions for SEO meta_title and meta_description (plain text).",
"categories.promptSection.metaFormulaNote": "SEO meta formulas from the description template are appended when present.",
"categories.promptSection.attributes": "Attributes",
"categories.promptSection.attributesHelp": "Instructions for the attrs object (allowlisted keys).",
"categories.promptSection.attributesFormulaNote": "Allowed attribute keys and title-formula attr slots are appended at render time.",
"categories.promptSection.schemaHeading": "One-shot JSON",
"categories.promptSection.schemaHelp": "Enhance returns a single JSON object. This section owns the keys below.",
"categories.promptSection.contentLabel": "Section instructions",
"categories.promptSection.contentPlaceholder": "Role guidance and placeholders for this section...",
"categories.promptSection.preambleToggle": "Shared intro (all sections)",
"categories.promptSection.preambleHelp": "Shown once above the section markers. Keep the JSON schema reminder so the model replies correctly.",
"categories.promptSection.clearLanguage": "Clear language override",
"categories.promptSection.unstructuredHint": "This prompt is not sectioned yet. Content is shown under Description - edit the sections you need and save to store the standard Title / Description / Meta / Attributes layout.",
"categories.promptSection.categorizeNote": "Categorize (taxonomy pick) is a separate pipeline step - not part of this enhance prompt.",
"contentLang.switcherLabel": "Content language",
"contentLang.primary": "primary",
"contentLang.hasOverride": "custom",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+8 -5
View File
@@ -1,6 +1,6 @@
import type { MessageDict } from "./types.ts";
/** Slovenian (sl) UI strings for admin Fix A1 †ready pack. Not registered in UI_LOCALES yet (avoid inventing locale switcher). */
/** Slovenian (sl) UI strings for admin Fix A1 — ready pack. Not registered in UI_LOCALES yet (avoid inventing locale switcher). */
export const sl: MessageDict = {
"admin.users.cloneCatalog": "Copy to my company",
"admin.users.cloneCatalogAria": "Copy {name} catalog into your sandbox company",
@@ -18,12 +18,15 @@ export const sl: MessageDict = {
"admin.users.syncA1": "Sinhroniziraj A1",
"admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
"admin.users.syncA1Title": "Sinhroniziraj katalog A1",
"admin.users.syncA1Desc": "Backfills mapped categories from the MySQL dump when present on the API host, then runs catalog hygiene (prompts, weak hashes, bidirectional category backfill, attribute sanitize). Does not wipe or reimport the full catalog.",
"admin.users.syncA1Desc": "Uploads wp_product_categories.sql to force-apply Title/Description/Meta/Attributes category prompts, optionally backfills mapped categories from a MySQL product dump on the API host, then runs catalog hygiene. Does not wipe or reimport the full catalog.",
"admin.users.syncA1Company": "Company: {name}",
"admin.users.syncA1Warning": "Requires descrybe_new.sql on the API server (scripts/seed/ or SEED_A1_MYSQL_DUMP) for dump category coverage. Without a dump, only DB hygiene runs. No mass reprocess.",
"admin.users.syncA1Cancel": "Prekliči",
"admin.users.syncA1Warning": "Upload wp_product_categories.sql below (primary). Auto-detect on the API host is a fallback when no file is chosen. Optional: place descrybe_new.sql on the API server for product-dump category coverage. No mass reprocess.",
"admin.users.syncA1Cancel": "Prekliči",
"admin.users.syncA1Confirm": "Sinhroniziraj A1",
"admin.users.syncA1WpUpload": "wp_product_categories.sql",
"admin.users.syncA1WpUploadHint": "Choose the WordPress category prompts dump. Max 16 MiB. When uploaded, Sync A1 force-applies sectioned prompts for title and description enhance.",
"admin.users.syncA1WpUploadClear": "Počisti datoteko",
"flash.admin.catalogCloned": "Copied {source} into {dest}: {products} products, {categories} taxonomy categories, {mapped_with} with mapped category.",
"flash.admin.syncA1Success": "Synced A1 for {name}: dump_mapped {dump_mapped} ({dump_status}), prompts {prompts}, hashes {hashes}, processedmapped {categories}, mappedprocessed {mapped_backfilled}. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).",
"flash.admin.syncA1Success": "Synced A1 for {name}: dump_mapped {dump_mapped} ({dump_status}), prompts {prompts}, hashes {hashes}, processed←mapped {categories}, mapped←processed {mapped_backfilled}. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).",
"flash.admin.syncA1Error": "Sinhronizacija A1 za {name} ni uspela.",
};
+4 -1
View File
@@ -1,4 +1,4 @@
/**
/**
* Focused check: admin Sync A1 i18n keys exist in every UI_LOCALES pack.
*/
import assert from "node:assert/strict";
@@ -16,6 +16,9 @@ const SYNC_A1_KEYS = [
"admin.users.syncA1Desc",
"admin.users.syncA1Company",
"admin.users.syncA1Warning",
"admin.users.syncA1WpUpload",
"admin.users.syncA1WpUploadHint",
"admin.users.syncA1WpUploadClear",
"admin.users.syncA1Cancel",
"admin.users.syncA1Confirm",
"flash.admin.syncA1Success",
+4
View File
@@ -20,6 +20,7 @@ export type Company = {
language?: string;
content_languages?: string[];
merge_products_by_gtin?: boolean;
owner_user_id?: string | null;
};
export type CreditBalance = {
@@ -94,6 +95,8 @@ export type MeResponse = {
companies?: Company[];
active_company_id?: string;
membership?: { role: string; status: string; staff_override?: boolean } | null;
/** True when the signed-in user is companies.owner_user_id for the active company. */
is_owner?: boolean;
credits?: CreditBalance | null;
staff_access?: StaffAccess | null;
staff_capabilities?: string[];
@@ -234,6 +237,7 @@ export type TeamMember = {
role?: string | null;
status?: string | null;
created_at?: string | null;
is_owner?: boolean;
};
export type ChannelSyncSummary = {
+81 -4
View File
@@ -66,6 +66,7 @@
let busyUserId = $state<string | null>(null);
let error = $state("");
let success = $state("");
let inviteAcceptLink = $state<string | null>(null);
let tab = $state<TabKey>("users");
let search = $state("");
let staffOnly = $state(false);
@@ -99,6 +100,7 @@
let syncOpen = $state(false);
let syncCompany = $state<AdminOrgCompany | null>(null);
let syncWpCategoriesFile = $state<File | null>(null);
const cloneDestLabel = $derived.by(() => {
const selected = cloneDestOptions.find((c) => c.id === cloneDestId);
@@ -407,6 +409,7 @@
function openSyncDialog(company: AdminOrgCompany) {
syncCompany = company;
syncWpCategoriesFile = null;
syncOpen = true;
error = "";
success = "";
@@ -468,7 +471,8 @@
const targetName = syncCompany.name;
try {
const res = await syncAdminCompanyA1(syncCompany.id, {
reprocessSampleLimit: 25
reprocessSampleLimit: 25,
wpProductCategoriesFile: syncWpCategoriesFile
});
const r = res.result;
const mappedWith = Number(r.mapped_with_category ?? 0);
@@ -489,6 +493,7 @@
});
syncOpen = false;
syncCompany = null;
syncWpCategoriesFile = null;
} catch (err) {
error = failureMessage(err, i18n.t("flash.admin.syncA1Error", { name: targetName }));
} finally {
@@ -500,6 +505,7 @@
busy = true;
error = "";
success = "";
inviteAcceptLink = null;
try {
const body = userId ? { user_id: userId } : {};
const res = await api<{
@@ -510,6 +516,7 @@
skipped_rate_limited?: number;
smtp_enabled: boolean;
token?: string;
accept_url?: string;
}>("/api/admin/emails/set-password", { method: "POST", body });
const parts = [
`sent ${res.sent}`,
@@ -518,9 +525,18 @@
];
if (res.skipped_synthetic) parts.push(`skipped test accounts ${res.skipped_synthetic}`);
if (res.skipped_rate_limited) parts.push(`rate-limited ${res.skipped_rate_limited}`);
success = i18n.t("flash.admin.invitesSummary", { parts: parts.join(", "), smtp: res.smtp_enabled ? i18n.t("flash.admin.smtpOn") : i18n.t("flash.admin.smtpOff") });
if (res.token) {
success += " Invite link available — copy and share it securely (email delivery is off).";
success = i18n.t("flash.admin.invitesSummary", {
parts: parts.join(", "),
smtp: res.smtp_enabled ? i18n.t("flash.admin.smtpOn") : i18n.t("flash.admin.smtpOff")
});
const link =
res.accept_url?.trim() ||
(res.token
? `${window.location.origin}/accept-invite?token=${encodeURIComponent(res.token)}`
: "");
if (link) {
inviteAcceptLink = link;
success += " " + i18n.t("admin.users.inviteLinkReady");
}
} catch (err) {
error = failureMessage(err, "Send failed");
@@ -529,6 +545,17 @@
}
}
async function copyInviteLink() {
if (!inviteAcceptLink) return;
try {
await navigator.clipboard.writeText(inviteAcceptLink);
success = i18n.t("admin.users.inviteLinkCopied");
error = "";
} catch {
error = i18n.t("flash.settings.copyFailed");
}
}
function openPasswordDialog(user: AdminOrgUser) {
passwordUser = user;
passwordValue = "";
@@ -613,6 +640,22 @@
{:else}
<Alert message={error} />
<Alert tone="success" message={success} />
{#if inviteAcceptLink}
<div class="mb-4 flex flex-col gap-2 rounded-md border border-border bg-muted/30 p-3 sm:flex-row sm:items-center">
<Input
value={inviteAcceptLink}
readonly
autocomplete="off"
spellcheck={false}
class="font-mono text-xs"
aria-label={i18n.t("admin.users.inviteLinkLabel")}
/>
<Button type="button" variant="outline" size="sm" onclick={() => copyInviteLink()}>
<Copy class="mr-2 h-4 w-4" />
{i18n.t("admin.users.copyInviteLink")}
</Button>
</div>
{/if}
{#if !staffRoleApiOk}
<Alert
tone="info"
@@ -1087,6 +1130,39 @@
{i18n.t("admin.users.syncA1Company", { name: syncCompany.name })}
</p>
{/if}
<div class="space-y-2">
<label class="block text-sm font-medium text-foreground" for="sync-a1-wp-categories">
{i18n.t("admin.users.syncA1WpUpload")}
</label>
<input
id="sync-a1-wp-categories"
type="file"
accept=".sql,text/plain,application/sql"
class="block w-full text-sm text-foreground file:mr-3 file:rounded-md file:border-0 file:bg-muted file:px-3 file:py-1.5 file:text-sm file:font-medium"
disabled={busy}
onchange={(e) => {
const input = e.currentTarget;
syncWpCategoriesFile = input.files?.[0] ?? null;
}}
/>
<p class="text-xs text-muted-foreground">{i18n.t("admin.users.syncA1WpUploadHint")}</p>
{#if syncWpCategoriesFile}
<div class="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{syncWpCategoriesFile.name}</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={busy}
onclick={() => {
syncWpCategoriesFile = null;
}}
>
{i18n.t("admin.users.syncA1WpUploadClear")}
</Button>
</div>
{/if}
</div>
<p class="text-sm text-muted-foreground">
{i18n.t("admin.users.syncA1Warning")}
</p>
@@ -1098,6 +1174,7 @@
onclick={() => {
syncOpen = false;
syncCompany = null;
syncWpCategoriesFile = null;
}}
>
{i18n.t("admin.users.syncA1Cancel")}
@@ -6,11 +6,21 @@
import { api, ApiError, failureMessage } from "$lib/api";
import type { Cat } from "$lib/categories/types";
import { findCategoryIdByUniqueId, listAllCategories, resolveCategory } from "$lib/categories/resolve";
import {
composeEnhancePrompt,
DEFAULT_ENHANCE_PREAMBLE,
ENHANCE_PROMPT_SECTIONS,
isEnhancePromptEmpty,
parseEnhancePrompt,
SECTION_SCHEMA_HINTS,
type EnhancePromptSectionId
} from "$lib/categories/prompt-sections";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import ContentLanguageSwitcher from "$lib/components/ContentLanguageSwitcher.svelte";
import {
Badge,
Button,
Card,
CardContent,
@@ -20,7 +30,16 @@
Label,
Textarea
} from "$lib/components/ui";
import { ArrowLeft, Share2, Sparkles, X } from "@lucide/svelte";
import {
ArrowLeft,
FileText,
Search,
Share2,
Sparkles,
Tags,
Type,
X
} from "@lucide/svelte";
import TreeSelectDialog from "$lib/components/categories/TreeSelectDialog.svelte";
import {
CONTENT_LANGUAGES,
@@ -31,16 +50,42 @@
const categoryParam = $derived(String(page.params.categoryId ?? ""));
const PROMPT_VARS = [
{ name: "name", label: i18n.t("categories.productName") },
{ name: "description", label: i18n.t("products.enrichment.piece.description") },
{ name: "category", label: i18n.t("products.enrichment.piece.category") },
{ name: "attrs", label: i18n.t("products.enrichment.piece.attributes") },
{ name: "gtin", label: i18n.t("categories.gtin") },
{ name: "brand_voice", label: i18n.t("categories.brandVoice") },
{ name: "language", label: i18n.t("settings.contentLanguage") }
const ALL_PROMPT_VARS = [
{ name: "name", labelKey: "categories.productName" },
{ name: "description", labelKey: "products.enrichment.piece.description" },
{ name: "category", labelKey: "products.enrichment.piece.category" },
{ name: "attrs", labelKey: "products.enrichment.piece.attributes" },
{ name: "gtin", labelKey: "categories.gtin" },
{ name: "brand_voice", labelKey: "categories.brandVoice" },
{ name: "language", labelKey: "settings.contentLanguage" }
] as const;
const SECTION_META: Record<
EnhancePromptSectionId,
{ labelKey: string; helpKey: string; formulaNoteKey: string }
> = {
title: {
labelKey: "categories.promptSection.title",
helpKey: "categories.promptSection.titleHelp",
formulaNoteKey: "categories.promptSection.titleFormulaNote"
},
description: {
labelKey: "categories.promptSection.description",
helpKey: "categories.promptSection.descriptionHelp",
formulaNoteKey: "categories.promptSection.descriptionFormulaNote"
},
meta: {
labelKey: "categories.promptSection.meta",
helpKey: "categories.promptSection.metaHelp",
formulaNoteKey: "categories.promptSection.metaFormulaNote"
},
attributes: {
labelKey: "categories.promptSection.attributes",
helpKey: "categories.promptSection.attributesHelp",
formulaNoteKey: "categories.promptSection.attributesFormulaNote"
}
};
let loading = $state(true);
let saving = $state(false);
let assigning = $state(false);
@@ -49,6 +94,7 @@
let success = $state("");
let category = $state<Cat | null>(null);
let allCategories = $state<Cat[]>([]);
/** Stored combined prompt per language (API shape). */
let promptsByLang = $state<Record<string, string>>({});
let selectedLang = $state(DEFAULT_CONTENT_LANGUAGE);
let extraLangs = $state<string[]>([]);
@@ -56,11 +102,28 @@
let primaryLang = $state(DEFAULT_CONTENT_LANGUAGE);
let assignOpen = $state(false);
const prompt = $derived(promptsByLang[selectedLang] ?? "");
let activeSection = $state<EnhancePromptSectionId>("title");
let preamble = $state(DEFAULT_ENHANCE_PREAMBLE);
let sectionBodies = $state<Record<EnhancePromptSectionId, string>>({
title: "",
description: "",
meta: "",
attributes: ""
});
let unstructuredHint = $state(false);
const langLabel = $derived(
CONTENT_LANGUAGES.find((l) => l.value === selectedLang)?.label ?? selectedLang
);
const schemaHint = $derived(SECTION_SCHEMA_HINTS[activeSection]);
const sectionMeta = $derived(SECTION_META[activeSection]);
const sectionBody = $derived(sectionBodies[activeSection] ?? "");
const sectionVars = $derived(
ALL_PROMPT_VARS.filter((v) => schemaHint.suggestedVars.includes(v.name))
);
const matchingUniqueIds = $derived.by(() => {
if (!category) return [] as string[];
const uid = String(category.unique_id ?? "");
@@ -73,6 +136,29 @@
return [];
});
function syncEditorFromStored(raw: string) {
const parsed = parseEnhancePrompt(raw);
preamble = parsed.preamble || DEFAULT_ENHANCE_PREAMBLE;
sectionBodies = { ...parsed.sections };
unstructuredHint = Boolean(raw.trim()) && !parsed.hasRoleSections;
}
function currentCombined(): string {
if (isEnhancePromptEmpty(preamble, sectionBodies)) return "";
return composeEnhancePrompt(preamble, sectionBodies);
}
function flushEditorToLang() {
const combined = currentCombined();
if (combined) {
promptsByLang = { ...promptsByLang, [selectedLang]: combined };
} else {
const next = { ...promptsByLang };
delete next[selectedLang];
promptsByLang = next;
}
}
function applyCategory(cat: Cat) {
category = cat;
const map: Record<string, string> = {};
@@ -90,6 +176,14 @@
if (!selectedLang || (!(selectedLang in map) && !contentLanguages.includes(selectedLang))) {
selectedLang = primaryLang;
}
syncEditorFromStored(map[selectedLang] ?? "");
}
function onLangChange(code: string) {
if (code === selectedLang) return;
flushEditorToLang();
selectedLang = code;
syncEditorFromStored(promptsByLang[code] ?? "");
}
async function load() {
@@ -125,21 +219,22 @@
void load();
});
function setPrompt(next: string) {
promptsByLang = { ...promptsByLang, [selectedLang]: next };
function setSectionBody(next: string) {
sectionBodies = { ...sectionBodies, [activeSection]: next };
unstructuredHint = false;
}
function insertVariable(name: string) {
const token = `{{${name}}}`;
const el = document.getElementById("category-prompt") as HTMLTextAreaElement | null;
const current = promptsByLang[selectedLang] ?? "";
const el = document.getElementById("category-prompt-section") as HTMLTextAreaElement | null;
const current = sectionBodies[activeSection] ?? "";
if (!el) {
setPrompt(`${current}${token}`);
setSectionBody(`${current}${token}`);
return;
}
const start = el.selectionStart ?? current.length;
const end = el.selectionEnd ?? current.length;
setPrompt(`${current.slice(0, start)}${token}${current.slice(end)}`);
setSectionBody(`${current.slice(0, start)}${token}${current.slice(end)}`);
queueMicrotask(() => {
el.focus();
const pos = start + token.length;
@@ -147,12 +242,27 @@
});
}
function clearActiveSection() {
setSectionBody("");
}
function clearLanguageOverride() {
preamble = DEFAULT_ENHANCE_PREAMBLE;
sectionBodies = { title: "", description: "", meta: "", attributes: "" };
unstructuredHint = false;
const next = { ...promptsByLang };
delete next[selectedLang];
promptsByLang = next;
}
async function savePrompt() {
if (!category) return;
saving = true;
error = "";
success = "";
try {
flushEditorToLang();
const bodyPrompts: Record<string, string> = {};
for (const [k, v] of Object.entries(promptsByLang)) {
if (v.trim()) bodyPrompts[k] = v;
@@ -177,6 +287,7 @@
}
async function openAssignDialog() {
flushEditorToLang();
assignOpen = true;
if (allCategories.length > 0) return;
try {
@@ -193,6 +304,7 @@
error = "";
success = "";
try {
flushEditorToLang();
const bodyPrompts: Record<string, string> = {};
for (const [k, v] of Object.entries(promptsByLang)) {
if (v.trim()) bodyPrompts[k] = v;
@@ -225,6 +337,10 @@
assignProgress = 0;
}
}
function sectionFilled(id: EnhancePromptSectionId): boolean {
return Boolean((sectionBodies[id] ?? "").trim());
}
</script>
<PageShell
@@ -234,13 +350,13 @@
{#if loading}
<div class="flex justify-center p-12"><Spinner label={i18n.t("categories.loadingPrompt")} /></div>
{:else if category}
<div class="mx-auto max-w-4xl space-y-6">
<div class="mx-auto max-w-6xl space-y-6">
<Alert message={error} />
<Alert tone="success" message={success} />
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="space-y-1">
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
<Button variant="ghost" size="sm" onclick={() => void goto("/categories")}>
<ArrowLeft class="h-4 w-4" />
{i18n.t("common.back")}
@@ -251,12 +367,14 @@
{i18n.t("categories.aiPromptOverrides", { name: category.name ?? "" })}
</p>
</div>
<div class="flex shrink-0 gap-2">
<div class="flex shrink-0 flex-wrap gap-2">
<Button variant="outline" onclick={() => void openAssignDialog()}>
<Share2 class="h-4 w-4" />
{i18n.t("categories.assign")}
</Button>
<Button variant="outline" onclick={() => void goto("/categories")}>{i18n.t("common.cancel")}</Button>
<Button variant="outline" onclick={() => void goto("/categories")}
>{i18n.t("common.cancel")}</Button
>
<Button onclick={savePrompt} loading={saving}>
<Sparkles class="h-4 w-4" />
{saving ? i18n.t("catalog.saving") : i18n.t("catalog.saveChanges")}
@@ -264,61 +382,168 @@
</div>
</div>
<Card>
<CardHeader>
<CardTitle>{i18n.t("categories.promptTemplate")}</CardTitle>
<CardDescription>
{i18n.t("categories.promptTemplateHelp")}
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<ContentLanguageSwitcher
bind:value={selectedLang}
bind:languages={extraLangs}
configured={contentLanguages}
primary={primaryLang}
hasOverride={(code) => Boolean((promptsByLang[code] ?? "").trim())}
/>
<div class="flex flex-wrap gap-2">
{#each PROMPT_VARS as v}
<Button type="button" variant="outline" size="sm" onclick={() => insertVariable(v.name)}>
{v.label}
<span class="font-mono text-xs text-muted-foreground">{"{{"}{v.name}{"}}"}</span>
</Button>
{/each}
{#if prompt.trim()}
<Button
type="button"
variant="ghost"
size="sm"
onclick={() => setPrompt("")}
>
<X class="h-4 w-4" />
{i18n.t("categories.clear")}
</Button>
{/if}
</div>
<div class="space-y-2">
<Label for="category-prompt">{i18n.t("categories.promptLabel")} ({langLabel})</Label>
<Textarea
id="category-prompt"
value={prompt}
oninput={(e) => setPrompt((e.currentTarget as HTMLTextAreaElement).value)}
rows={18}
class="min-h-[280px] font-mono text-sm"
placeholder={'Ustvari nov opis… Star_opis_izdelka: {{description}}; …'}
/>
<p class="text-xs text-muted-foreground">
{i18n.t("categories.charactersCount", { count: prompt.length.toLocaleString() })}
{#if prompt.trim()}
{i18n.t("categories.activeOverride")}
{:else}
{i18n.t("categories.usingCompanyDefaultForLang")}
{/if}
<div class="flex flex-wrap items-center justify-between gap-3">
<ContentLanguageSwitcher
value={selectedLang}
bind:languages={extraLangs}
configured={contentLanguages}
primary={primaryLang}
hasOverride={(code) => Boolean((promptsByLang[code] ?? "").trim())}
onChange={onLangChange}
/>
<Button
type="button"
variant="ghost"
size="sm"
onclick={clearLanguageOverride}
disabled={!Object.values(sectionBodies).some((v) => v.trim()) &&
!(promptsByLang[selectedLang] ?? "").trim()}
>
<X class="h-4 w-4" />
{i18n.t("categories.promptSection.clearLanguage")}
</Button>
</div>
{#if unstructuredHint}
<Alert tone="info" message={i18n.t("categories.promptSection.unstructuredHint")} />
{/if}
<div class="grid gap-4 lg:grid-cols-[220px_minmax(0,1fr)]">
<aside class="space-y-3 rounded-lg border bg-card/40 p-3" aria-label={i18n.t("categories.promptSection.navLabel")}>
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{i18n.t("categories.promptSection.navHeading")}
</p>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("categories.promptSection.navHint")}
</p>
</div>
</CardContent>
</Card>
<ul class="space-y-0.5">
{#each ENHANCE_PROMPT_SECTIONS as id (id)}
{@const meta = SECTION_META[id]}
<li>
<button
type="button"
class="flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm {activeSection ===
id
? 'bg-muted font-medium'
: 'hover:bg-muted/60'}"
onclick={() => (activeSection = id)}
>
{#if id === "title"}
<Type class="h-4 w-4 shrink-0 text-muted-foreground" />
{:else if id === "description"}
<FileText class="h-4 w-4 shrink-0 text-muted-foreground" />
{:else if id === "meta"}
<Search class="h-4 w-4 shrink-0 text-muted-foreground" />
{:else}
<Tags class="h-4 w-4 shrink-0 text-muted-foreground" />
{/if}
<span class="min-w-0 flex-1 truncate">{i18n.t(meta.labelKey)}</span>
{#if sectionFilled(id)}
<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-foreground/60" aria-hidden="true"
></span>
{/if}
</button>
</li>
{/each}
</ul>
<p class="border-t pt-3 text-xs text-muted-foreground">
{i18n.t("categories.promptSection.categorizeNote")}
</p>
</aside>
<div class="min-w-0 space-y-4">
<Card>
<CardHeader>
<CardTitle>{i18n.t(sectionMeta.labelKey)}</CardTitle>
<CardDescription>{i18n.t(sectionMeta.helpKey)}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="rounded-md border bg-muted/30 p-3 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{i18n.t("categories.promptSection.schemaHeading")}
</span>
{#each schemaHint.jsonKeys as key}
<Badge variant="secondary" class="font-mono text-xs">{key}</Badge>
{/each}
</div>
<p class="text-xs text-muted-foreground">
{i18n.t("categories.promptSection.schemaHelp")}
</p>
<pre class="overflow-x-auto rounded bg-background/80 px-2 py-1.5 font-mono text-xs text-foreground/90">{schemaHint.example}</pre>
<p class="text-xs text-muted-foreground">{i18n.t(sectionMeta.formulaNoteKey)}</p>
</div>
<div class="flex flex-wrap gap-2">
{#each sectionVars as v}
<Button
type="button"
variant="outline"
size="sm"
onclick={() => insertVariable(v.name)}
>
{i18n.t(v.labelKey)}
<span class="font-mono text-xs text-muted-foreground"
>{"{{"}{v.name}{"}}"}</span
>
</Button>
{/each}
{#if sectionBody.trim()}
<Button type="button" variant="ghost" size="sm" onclick={clearActiveSection}>
<X class="h-4 w-4" />
{i18n.t("categories.clear")}
</Button>
{/if}
</div>
<div class="space-y-2">
<Label for="category-prompt-section">
{i18n.t("categories.promptSection.contentLabel")} ({langLabel})
</Label>
<Textarea
id="category-prompt-section"
value={sectionBody}
oninput={(e) => setSectionBody((e.currentTarget as HTMLTextAreaElement).value)}
rows={14}
class="min-h-[220px] font-mono text-sm"
placeholder={i18n.t("categories.promptSection.contentPlaceholder")}
/>
<p class="text-xs text-muted-foreground">
{i18n.t("categories.charactersCount", {
count: sectionBody.length.toLocaleString()
})}
{#if (promptsByLang[selectedLang] ?? "").trim() || Object.values(sectionBodies).some((v) => v.trim())}
{i18n.t("categories.activeOverride")}
{:else}
{i18n.t("categories.usingCompanyDefaultForLang")}
{/if}
</p>
</div>
<details class="rounded-md border p-3">
<summary class="cursor-pointer text-sm font-medium">
{i18n.t("categories.promptSection.preambleToggle")}
</summary>
<div class="mt-3 space-y-2">
<p class="text-xs text-muted-foreground">
{i18n.t("categories.promptSection.preambleHelp")}
</p>
<Textarea
id="category-prompt-preamble"
value={preamble}
oninput={(e) =>
(preamble = (e.currentTarget as HTMLTextAreaElement).value)}
rows={4}
class="font-mono text-sm"
/>
</div>
</details>
</CardContent>
</Card>
</div>
</div>
</div>
<TreeSelectDialog
@@ -333,4 +558,4 @@
onSave={assignPrompt}
/>
{/if}
</PageShell>
</PageShell>
+51 -3
View File
@@ -185,6 +185,7 @@
let apiKeys = $state<ApiKey[]>([]);
let pendingInvites = $state<PendingInvite[]>([]);
let canAdmin = $state(false);
let canOwner = $state(false);
let accessDenied = $state(false);
let teamForbidden = $state(false);
let apiKeysForbidden = $state(false);
@@ -261,6 +262,7 @@
if (ac.signal.aborted) return;
authSession.setMe(me);
canAdmin = canManageCompany(me);
canOwner = Boolean(me.is_owner) || Boolean(me.staff_access?.full_admin);
user = me.user;
company = me.company ?? null;
credits = me.credits ?? null;
@@ -573,6 +575,7 @@
id: string;
email: string;
token?: string;
accept_url?: string;
mail_sent?: boolean;
smtp_enabled?: boolean;
}>("/api/team/invites", {
@@ -591,7 +594,7 @@
mail_sent: Boolean(created.mail_sent)
});
if (created.token) {
inviteAcceptLink = `${window.location.origin}/accept-invite?token=${encodeURIComponent(created.token)}`;
inviteAcceptLink = created.accept_url?.trim() || `${window.location.origin}/accept-invite?token=${encodeURIComponent(created.token)}`;
inviteOpen = false;
success = i18n.t("settings.inviteCreatedNoMail", { email: emailed, role: roleName });
} else {
@@ -727,6 +730,35 @@
}
}
async function transferOwnership(member: TeamMember) {
const userId = member.user_id ?? member.id;
if (!userId) return;
if (!confirm(i18n.t("settings.transferOwnershipConfirm", { email: member.email }))) {
return;
}
saving = true;
clearFormFeedback();
try {
await api("/api/team/transfer-ownership", {
method: "POST",
body: { user_id: userId }
});
team = team.map((m) => ({
...m,
is_owner: (m.user_id ?? m.id) === userId,
role: (m.user_id ?? m.id) === userId ? "admin" : m.role
}));
canOwner = Boolean(user && user.id === userId);
success = i18n.t("settings.ownershipTransferred", { email: member.email });
notifySuccess(success);
} catch (err) {
error = applyApiFormError(err, i18n.t("settings.transferFailed"), "transfer ownership");
} finally {
saving = false;
}
}
async function copyText(text: string, okMessage = i18n.t("common.copied")) {
try {
await navigator.clipboard.writeText(text);
@@ -1528,7 +1560,14 @@
{#each team as member}
<TableRow>
<TableCell>{member.email}</TableCell>
<TableCell>{roleLabel(member.role)}</TableCell>
<TableCell>
<div class="flex flex-wrap items-center gap-2">
<span>{roleLabel(member.role)}</span>
{#if member.is_owner}
<Badge variant="secondary">{i18n.t("settings.ownerBadge")}</Badge>
{/if}
</div>
</TableCell>
<TableCell>
<Badge variant="default">{i18n.t("common.active")}</Badge>
</TableCell>
@@ -1581,9 +1620,18 @@
{i18n.t("settings.makeAdmin")}
</DropdownMenuItem>
{/if}
{#if canOwner && !member.is_owner}
<DropdownMenuItem
disabled={saving}
onclick={() => transferOwnership(member)}
>
{i18n.t("settings.transferOwnership")}
</DropdownMenuItem>
{/if}
<DropdownMenuItem
class="text-destructive focus:text-destructive"
disabled={saving}
disabled={saving || Boolean(member.is_owner)}
title={member.is_owner ? i18n.t("settings.cannotRemoveOwner") : undefined}
onclick={() => removeMember(member)}
>
{i18n.t("settings.removeMember")}