feat(plans): reactivate + delete-when-unused; card layout fixes overlap
Addresses three issues with the plan catalog screen: 1. Retired plans had NO actions (the action cell was gated on "current version", which a retired plan lacks) — so there was no way to make one in-force again. Add POST /:planId/reactivate (inverse of retire) + a Reactivate button on retired plans. 2. No delete. Add DELETE /:planId, allowed ONLY when zero subscriptions reference the planId (any version) — a referenced plan version must survive for reproducible repricing/audit, so an in-use delete returns 409 and the UI says "retire it instead". The Delete button only shows when the plan has 0 subscribers. 3. The 6-column table overflowed max-w-3xl: action buttons overlapped and the status badges wrapped to a second line. Replace it with a CARD list (one card per planId, grouped across versions): name + status on top, price · hours · effective on a wrap row, "used by N" expandable to holder names, and actions on their own bordered row — nothing overlaps, badges stay inline. Build+lint 12/12 (i18n parity). Verified on a DB copy: unused plans report deletable; retire→reactivate flips active back. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { desc, eq, subscriptionPlans, type Db } from "@parking/db";
|
import { desc, eq, subscriptionPlans, subscriptions, type Db } from "@parking/db";
|
||||||
import { SUBSCRIPTION_PERIODS, type PlanTimeframes, type SubscriptionPeriod } from "@parking/shared";
|
import { SUBSCRIPTION_PERIODS, type PlanTimeframes, type SubscriptionPeriod } from "@parking/shared";
|
||||||
import { requirePermission } from "../auth.js";
|
import { requirePermission } from "../auth.js";
|
||||||
import { siteTz } from "../subscription-window.js";
|
import { siteTz } from "../subscription-window.js";
|
||||||
@@ -124,7 +124,7 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Retire a plan (soft): mark every version of this planId inactive so it's no longer
|
// Retire a plan (soft): mark every version of this planId inactive so it's no longer
|
||||||
// sellable. History (and past sales' planVersionId) is preserved. Re-publish to revive.
|
// sellable. History (and past sales' planVersionId) is preserved. Reactivate to revive.
|
||||||
app.post<{ Params: { planId: string } }>(
|
app.post<{ Params: { planId: string } }>(
|
||||||
"/api/subscription-plans/:planId/retire",
|
"/api/subscription-plans/:planId/retire",
|
||||||
{ preHandler: planGuard },
|
{ preHandler: planGuard },
|
||||||
@@ -136,4 +136,39 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom
|
|||||||
return { planId: req.params.planId, retired: true };
|
return { planId: req.params.planId, retired: true };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// REACTIVATE a retired plan: mark its versions active again so it's sellable. The
|
||||||
|
// latest-effective version becomes "in force" again. (The inverse of retire.)
|
||||||
|
app.post<{ Params: { planId: string } }>(
|
||||||
|
"/api/subscription-plans/:planId/reactivate",
|
||||||
|
{ preHandler: planGuard },
|
||||||
|
async (req) => {
|
||||||
|
db.update(subscriptionPlans)
|
||||||
|
.set({ active: true })
|
||||||
|
.where(eq(subscriptionPlans.planId, req.params.planId))
|
||||||
|
.run();
|
||||||
|
return { planId: req.params.planId, reactivated: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// DELETE a plan entirely — allowed ONLY when NO subscription references it (any
|
||||||
|
// version). A referenced plan version MUST survive: a subscription's planVersionId is
|
||||||
|
// needed to reprice/audit that sale, so deleting it would dangle. 409 with the count
|
||||||
|
// when in use (the admin should retire instead). Removes all versions of the planId.
|
||||||
|
app.delete<{ Params: { planId: string } }>(
|
||||||
|
"/api/subscription-plans/:planId",
|
||||||
|
{ preHandler: planGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const refs = db.select().from(subscriptions).where(eq(subscriptions.planId, req.params.planId)).all();
|
||||||
|
if (refs.length > 0) {
|
||||||
|
return reply.code(409).send({
|
||||||
|
error: "plan is in use and cannot be deleted",
|
||||||
|
code: "plan_in_use",
|
||||||
|
subscribers: refs.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
db.delete(subscriptionPlans).where(eq(subscriptionPlans.planId, req.params.planId)).run();
|
||||||
|
return { planId: req.params.planId, deleted: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Fragment, useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
createSubscriptionPlan,
|
createSubscriptionPlan,
|
||||||
|
deleteSubscriptionPlan,
|
||||||
fetchSubscriptionPlans,
|
fetchSubscriptionPlans,
|
||||||
fetchSubscriptions,
|
fetchSubscriptions,
|
||||||
|
reactivateSubscriptionPlan,
|
||||||
retireSubscriptionPlan,
|
retireSubscriptionPlan,
|
||||||
type PlanTimeframes,
|
type PlanTimeframes,
|
||||||
type Subscription,
|
type Subscription,
|
||||||
@@ -171,6 +173,29 @@ export function SubscriptionPlansManager() {
|
|||||||
await retireSubscriptionPlan(p.planId).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
await retireSubscriptionPlan(p.planId).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||||
reload();
|
reload();
|
||||||
}
|
}
|
||||||
|
async function reactivate(p: SubscriptionPlan) {
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
await reactivateSubscriptionPlan(p.planId);
|
||||||
|
setMsg({ kind: "ok", text: t("plans.reactivated", { name: p.name }) });
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
setMsg({ kind: "err", text: (e as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function del(p: SubscriptionPlan) {
|
||||||
|
if (!confirm(t("plans.confirmDelete", { name: p.name }))) return;
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
await deleteSubscriptionPlan(p.planId);
|
||||||
|
setMsg({ kind: "ok", text: t("plans.deleted", { name: p.name }) });
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
// 409 → plan is in use; explain why it can't be deleted (retire instead).
|
||||||
|
const inUse = e instanceof ApiError && e.status === 409;
|
||||||
|
setMsg({ kind: "err", text: inUse ? t("plans.deleteInUse") : (e as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Publish a new version of an existing plan (pre-fills its identity + last values). */
|
/** Publish a new version of an existing plan (pre-fills its identity + last values). */
|
||||||
function newVersionOf(p: SubscriptionPlan) {
|
function newVersionOf(p: SubscriptionPlan) {
|
||||||
@@ -192,17 +217,25 @@ export function SubscriptionPlansManager() {
|
|||||||
|
|
||||||
if (!plans) return null;
|
if (!plans) return null;
|
||||||
|
|
||||||
// The CURRENT (latest active) version per planId, for the "in force" badge.
|
// GROUP versions by planId; the newest version (plans come newest-first) represents the
|
||||||
|
// plan in the list. A planId is "in force" when its versions are active; "retired"
|
||||||
|
// otherwise. One card per planId — avoids the cramped multi-version table.
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const currentVersionId = new Map<string, string>();
|
const groups: { planId: string; head: SubscriptionPlan; active: boolean; versions: number }[] = [];
|
||||||
|
const seen = new Map<string, number>();
|
||||||
for (const p of plans) {
|
for (const p of plans) {
|
||||||
if (p.active && p.effectiveFrom <= now && !currentVersionId.has(p.planId)) {
|
const idx = seen.get(p.planId);
|
||||||
currentVersionId.set(p.planId, p.id); // plans come newest-first
|
if (idx == null) {
|
||||||
|
seen.set(p.planId, groups.length);
|
||||||
|
groups.push({ planId: p.planId, head: p, active: p.active && p.effectiveFrom <= now, versions: 1 });
|
||||||
|
} else {
|
||||||
|
groups[idx]!.versions += 1;
|
||||||
|
if (p.active && p.effectiveFrom <= now) groups[idx]!.active = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mx-auto max-w-3xl px-4 py-6">
|
<section className="mx-auto max-w-2xl px-4 py-6">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<h3 className="text-[13px] font-semibold uppercase tracking-wider text-term-muted">{t("plans.title")}</h3>
|
<h3 className="text-[13px] font-semibold uppercase tracking-wider text-term-muted">{t("plans.title")}</h3>
|
||||||
<button type="button" className="btn btn-go btn-sm" onClick={() => setForm(emptyForm())}>
|
<button type="button" className="btn btn-go btn-sm" onClick={() => setForm(emptyForm())}>
|
||||||
@@ -215,93 +248,89 @@ export function SubscriptionPlansManager() {
|
|||||||
<div className={`mb-3 text-[12px] ${msg.kind === "ok" ? "text-term-green" : "text-term-red"}`}>{msg.text}</div>
|
<div className={`mb-3 text-[12px] ${msg.kind === "ok" ? "text-term-green" : "text-term-red"}`}>{msg.text}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{plans.length === 0 ? (
|
{groups.length === 0 ? (
|
||||||
<p className="text-[13px] text-term-muted">{t("plans.noneYet")}</p>
|
<p className="text-[13px] text-term-muted">{t("plans.noneYet")}</p>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-left text-[13px]">
|
<div className="flex flex-col gap-2">
|
||||||
<thead className="text-[11px] uppercase tracking-wider text-term-muted">
|
{groups.map(({ planId, head: p, active, versions }) => {
|
||||||
<tr>
|
const users = subscribersOf(planId);
|
||||||
<th className="py-1">{t("plans.colName")}</th>
|
const activeUsers = users.filter((s) => s.status === "active");
|
||||||
<th className="py-1">{t("plans.colPrice")}</th>
|
const isOpen = expanded === planId;
|
||||||
<th className="py-1">{t("plans.colHours")}</th>
|
const canDelete = users.length === 0; // no sale references it → safe to delete
|
||||||
<th className="py-1">{t("plans.colUsedBy")}</th>
|
return (
|
||||||
<th className="py-1">{t("plans.colEffective")}</th>
|
<div key={planId} className={`card p-3 ${active ? "" : "opacity-70"}`}>
|
||||||
<th className="py-1" />
|
{/* Header: name + status badge */}
|
||||||
</tr>
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
</thead>
|
<span className="font-semibold text-term-text">{p.name}</span>
|
||||||
<tbody>
|
{active ? (
|
||||||
{plans.map((p) => {
|
<span className="rounded border border-term-green px-1 text-[10px] text-term-green">{t("plans.inForce")}</span>
|
||||||
const isCurrent = currentVersionId.get(p.planId) === p.id;
|
) : (
|
||||||
// Count subscribers only against the CURRENT row of each planId (the list
|
<span className="rounded border border-term-border px-1 text-[10px] text-term-muted">{t("plans.retired")}</span>
|
||||||
// shows all versions; we don't want to double-count per version).
|
|
||||||
const users = isCurrent ? subscribersOf(p.planId) : [];
|
|
||||||
const activeUsers = users.filter((s) => s.status === "active");
|
|
||||||
const isOpen = expanded === p.planId;
|
|
||||||
return (
|
|
||||||
<Fragment key={p.id}>
|
|
||||||
<tr className="border-t border-term-border">
|
|
||||||
<td className="py-1.5">
|
|
||||||
{p.name}
|
|
||||||
{isCurrent && <span className="ml-2 rounded border border-term-green px-1 text-[10px] text-term-green">{t("plans.inForce")}</span>}
|
|
||||||
{!p.active && <span className="ml-2 text-[10px] text-term-muted">{t("plans.retired")}</span>}
|
|
||||||
</td>
|
|
||||||
<td className="py-1.5 tabular-nums">
|
|
||||||
{(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
|
|
||||||
</td>
|
|
||||||
<td className="py-1.5 text-term-muted">{timeframesSummary(p.timeframes, t)}</td>
|
|
||||||
<td className="py-1.5">
|
|
||||||
{isCurrent ? (
|
|
||||||
users.length > 0 ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-term-cyan hover:underline tabular-nums"
|
|
||||||
onClick={() => setExpanded(isOpen ? null : p.planId)}
|
|
||||||
title={t("plans.usedByTitle")}
|
|
||||||
>
|
|
||||||
{t("plans.usedByCount", { active: activeUsers.length, total: users.length })} {isOpen ? "▾" : "▸"}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="text-term-muted">{t("plans.usedByNone")}</span>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<span className="text-term-muted">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="py-1.5 text-term-muted">{new Date(p.effectiveFrom).toLocaleDateString()}</td>
|
|
||||||
<td className="py-1.5 text-right">
|
|
||||||
{isCurrent && (
|
|
||||||
<>
|
|
||||||
<button type="button" className="btn btn-sm" onClick={() => newVersionOf(p)}>
|
|
||||||
{t("plans.newVersion")}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-sm btn-danger ml-1" onClick={() => retire(p)}>
|
|
||||||
{t("plans.retire")}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{isOpen && users.length > 0 && (
|
|
||||||
<tr className="bg-term-bg">
|
|
||||||
<td colSpan={6} className="px-3 py-2">
|
|
||||||
<div className="text-[11px] uppercase tracking-wider text-term-muted">{t("plans.subscribers")}</div>
|
|
||||||
<ul className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-[12px]">
|
|
||||||
{users.map((s) => (
|
|
||||||
<li key={s.id} className={s.status === "active" ? "text-term-text" : "text-term-muted"}>
|
|
||||||
{s.holderName || t("subs.unnamed")}
|
|
||||||
{s.quantity > 1 && <span className="text-term-muted"> ×{s.quantity}</span>}
|
|
||||||
{s.status !== "active" && <span className="ml-1 text-[10px]">({t(STATUS_KEY[s.status])})</span>}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
)}
|
||||||
</Fragment>
|
{versions > 1 && <span className="text-[10px] text-term-muted">{t("plans.versionCount", { count: versions })}</span>}
|
||||||
);
|
</div>
|
||||||
})}
|
|
||||||
</tbody>
|
{/* Details: price · hours · effective */}
|
||||||
</table>
|
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 text-[12px] text-term-muted">
|
||||||
|
<span className="tabular-nums text-term-text">
|
||||||
|
{(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
|
||||||
|
</span>
|
||||||
|
<span>{timeframesSummary(p.timeframes, t)}</span>
|
||||||
|
<span>{t("plans.colEffective")}: {new Date(p.effectiveFrom).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Used by */}
|
||||||
|
<div className="mt-1 text-[12px]">
|
||||||
|
{users.length > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-term-cyan hover:underline tabular-nums"
|
||||||
|
onClick={() => setExpanded(isOpen ? null : planId)}
|
||||||
|
title={t("plans.usedByTitle")}
|
||||||
|
>
|
||||||
|
{t("plans.colUsedBy")}: {t("plans.usedByCount", { active: activeUsers.length, total: users.length })} {isOpen ? "▾" : "▸"}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-term-muted">{t("plans.colUsedBy")}: {t("plans.usedByNone")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isOpen && users.length > 0 && (
|
||||||
|
<ul className="mt-1 flex flex-wrap gap-x-4 gap-y-1 rounded-term bg-term-bg px-3 py-2 text-[12px]">
|
||||||
|
{users.map((s) => (
|
||||||
|
<li key={s.id} className={s.status === "active" ? "text-term-text" : "text-term-muted"}>
|
||||||
|
{s.holderName || t("subs.unnamed")}
|
||||||
|
{s.quantity > 1 && <span className="text-term-muted"> ×{s.quantity}</span>}
|
||||||
|
{s.status !== "active" && <span className="ml-1 text-[10px]">({t(STATUS_KEY[s.status])})</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions — own row, never overlapping */}
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5 border-t border-term-border pt-2">
|
||||||
|
{active ? (
|
||||||
|
<>
|
||||||
|
<button type="button" className="btn btn-sm" onClick={() => newVersionOf(p)}>{t("plans.newVersion")}</button>
|
||||||
|
<button type="button" className="btn btn-sm btn-danger" onClick={() => retire(p)}>{t("plans.retire")}</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="btn btn-go btn-sm" onClick={() => reactivate(p)}>{t("plans.reactivate")}</button>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-danger"
|
||||||
|
onClick={() => del(p)}
|
||||||
|
title={t("plans.deleteTitle")}
|
||||||
|
>
|
||||||
|
{t("plans.delete")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Modal open={form != null} onClose={() => setForm(null)} title={form?.planId ? t("plans.newVersionTitle") : t("plans.newTitle")} width="max-w-lg">
|
<Modal open={form != null} onClose={() => setForm(null)} title={form?.planId ? t("plans.newVersionTitle") : t("plans.newTitle")} width="max-w-lg">
|
||||||
|
|||||||
@@ -614,6 +614,13 @@ export function createSubscriptionPlan(body: {
|
|||||||
export function retireSubscriptionPlan(planId: string): Promise<{ planId: string; retired: boolean }> {
|
export function retireSubscriptionPlan(planId: string): Promise<{ planId: string; retired: boolean }> {
|
||||||
return apiFetch(`/api/subscription-plans/${encodeURIComponent(planId)}/retire`, { method: "POST" });
|
return apiFetch(`/api/subscription-plans/${encodeURIComponent(planId)}/retire`, { method: "POST" });
|
||||||
}
|
}
|
||||||
|
export function reactivateSubscriptionPlan(planId: string): Promise<{ planId: string; reactivated: boolean }> {
|
||||||
|
return apiFetch(`/api/subscription-plans/${encodeURIComponent(planId)}/reactivate`, { method: "POST" });
|
||||||
|
}
|
||||||
|
/** Delete a plan (all versions). Rejects (409 plan_in_use) if any subscription uses it. */
|
||||||
|
export function deleteSubscriptionPlan(planId: string): Promise<{ planId: string; deleted: boolean }> {
|
||||||
|
return apiFetch(`/api/subscription-plans/${encodeURIComponent(planId)}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
/** Live quote for the sell form (server-computed; the operator can't override it). */
|
/** Live quote for the sell form (server-computed; the operator can't override it). */
|
||||||
export function quoteSubscription(body: {
|
export function quoteSubscription(body: {
|
||||||
planId: string;
|
planId: string;
|
||||||
|
|||||||
@@ -464,8 +464,16 @@ export const en: Catalog = {
|
|||||||
subscribers: "Subscribers",
|
subscribers: "Subscribers",
|
||||||
inForce: "in force",
|
inForce: "in force",
|
||||||
retired: "retired",
|
retired: "retired",
|
||||||
|
versionCount: "{{count}} versions",
|
||||||
newVersion: "New version",
|
newVersion: "New version",
|
||||||
retire: "Retire",
|
retire: "Retire",
|
||||||
|
reactivate: "Reactivate",
|
||||||
|
delete: "Delete",
|
||||||
|
deleteTitle: "Delete this plan permanently (only when no subscription uses it)",
|
||||||
|
reactivated: "“{{name}}” is sellable again.",
|
||||||
|
confirmDelete: "Delete the plan “{{name}}” permanently? This can't be undone.",
|
||||||
|
deleted: "Plan “{{name}}” deleted.",
|
||||||
|
deleteInUse: "Can't delete — subscriptions still use this plan. Retire it instead.",
|
||||||
newTitle: "New plan",
|
newTitle: "New plan",
|
||||||
newVersionTitle: "Publish new version",
|
newVersionTitle: "Publish new version",
|
||||||
newVersionHint: "This publishes a NEW version of the plan — existing sales keep their original price.",
|
newVersionHint: "This publishes a NEW version of the plan — existing sales keep their original price.",
|
||||||
|
|||||||
@@ -475,8 +475,16 @@ export const sq = {
|
|||||||
subscribers: "Abonentët",
|
subscribers: "Abonentët",
|
||||||
inForce: "në fuqi",
|
inForce: "në fuqi",
|
||||||
retired: "i tërhequr",
|
retired: "i tërhequr",
|
||||||
|
versionCount: "{{count}} versione",
|
||||||
newVersion: "Version i ri",
|
newVersion: "Version i ri",
|
||||||
retire: "Tërhiq",
|
retire: "Tërhiq",
|
||||||
|
reactivate: "Riaktivizo",
|
||||||
|
delete: "Fshij",
|
||||||
|
deleteTitle: "Fshij këtë plan përgjithmonë (vetëm kur asnjë abonim nuk e përdor)",
|
||||||
|
reactivated: "“{{name}}” është përsëri i shitshëm.",
|
||||||
|
confirmDelete: "Të fshihet plani “{{name}}” përgjithmonë? Kjo s'mund të kthehet.",
|
||||||
|
deleted: "Plani “{{name}}” u fshi.",
|
||||||
|
deleteInUse: "S'mund të fshihet — abonime ende e përdorin këtë plan. Tërhiqe në vend të kësaj.",
|
||||||
newTitle: "Plan i ri",
|
newTitle: "Plan i ri",
|
||||||
newVersionTitle: "Publiko version të ri",
|
newVersionTitle: "Publiko version të ri",
|
||||||
newVersionHint: "Kjo publikon një version TË RI të planit — shitjet ekzistuese ruajnë çmimin origjinal.",
|
newVersionHint: "Kjo publikon një version TË RI të planit — shitjet ekzistuese ruajnë çmimin origjinal.",
|
||||||
|
|||||||
Reference in New Issue
Block a user