feat(admin): grant a free subscription to any café from the admin panel
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m43s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 33s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m14s
CI/CD / CI · Admin Web (tsc) (push) Successful in 40s
CI/CD / CI · Website (tsc) (push) Successful in 47s
CI/CD / CI · Koja (tsc) (push) Successful in 54s
CI/CD / Deploy · all services (push) Successful in 5m13s

Adds POST /api/admin/cafes/{cafeId}/grant-subscription (admin-auth): sets the
café's plan and adds N months of coverage, appended to any time it already has so
a grant never shortens existing paid time. Records the gift as a SubscriptionPayment
(provider Manual, amount 0, Completed) for billing history/audit. New
PaymentProvider.Manual = 4 (int append, no migration). Admin-web café cards get a
"grant free subscription" panel (plan select + months + apply), showing the current
expiry; fa/en/ar strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-27 19:41:33 +03:30
parent 4cc1c3a423
commit 352c3b41cb
8 changed files with 155 additions and 1 deletions
+9
View File
@@ -1159,6 +1159,15 @@
"save": "حفظ",
"saved": "تم الحفظ",
"loading": "جاري التحميل..."
},
"grant": {
"title": "منح اشتراك مجاني",
"plan": "الباقة",
"months": "عدد الأشهر",
"submit": "منح",
"granted": "تم منح الاشتراك",
"failed": "تعذّر منح الاشتراك",
"currentExpiry": "انتهاء الصلاحية الحالي"
}
},
"integrations": {
+9
View File
@@ -1152,6 +1152,15 @@
"save": "Save",
"saved": "Saved",
"loading": "Loading..."
},
"grant": {
"title": "Grant free subscription",
"plan": "Plan",
"months": "Months",
"submit": "Grant",
"granted": "Subscription granted",
"failed": "Could not grant subscription",
"currentExpiry": "Current expiry"
}
},
"integrations": {
+9
View File
@@ -1152,6 +1152,15 @@
"save": "ذخیره",
"saved": "ذخیره شد",
"loading": "در حال بارگذاری..."
},
"grant": {
"title": "افزودن اشتراک رایگان",
"plan": "پلن",
"months": "تعداد ماه",
"submit": "اعطا",
"granted": "اشتراک اعطا شد",
"failed": "اعطای اشتراک ناموفق بود",
"currentExpiry": "انقضای فعلی"
}
},
"integrations": {
@@ -493,6 +493,7 @@ export function AdminCafesScreen() {
</Button>
</div>
</div>
<GrantSubscriptionPanel cafe={c} />
<RecoveryKeyPanel cafe={c} />
{profileCafeId === c.id ? (
<CafeDiscoverProfilePanel cafeId={c.id} mode="admin" compact />
@@ -504,6 +505,70 @@ export function AdminCafesScreen() {
);
}
/** Gift a café a free subscription: pick a plan + number of months and apply.
* Months are appended to any coverage the café already has. */
function GrantSubscriptionPanel({ cafe }: { cafe: AdminCafe }) {
const t = useTranslations("admin.cafes.grant");
const qc = useQueryClient();
const [tier, setTier] = useState("Pro");
const [months, setMonths] = useState(1);
const TIERS = ["Starter", "Pro", "Business", "Enterprise"];
const grant = useMutation({
mutationFn: () =>
adminPost(`/api/admin/cafes/${cafe.id}/grant-subscription`, { planTier: tier, months }),
onSuccess: () => {
notify.success(t("granted"));
void qc.invalidateQueries({ queryKey: ["admin", "cafes"] });
},
onError: () => notify.error(t("failed")),
});
return (
<div className="space-y-2 rounded-lg border border-border/70 bg-muted/20 p-3 text-sm">
<p className="font-medium">{t("title")}</p>
<div className="flex flex-wrap items-end gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">{t("plan")}</span>
<select
value={tier}
onChange={(e) => setTier(e.target.value)}
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
>
{TIERS.map((x) => (
<option key={x} value={x}>
{x}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">{t("months")}</span>
<Input
type="number"
min={1}
max={120}
value={months}
onChange={(e) =>
setMonths(Math.max(1, Math.min(120, Number(e.target.value) || 1)))
}
className="h-9 w-24"
/>
</label>
<Button size="sm" disabled={grant.isPending} onClick={() => grant.mutate()}>
{t("submit")}
</Button>
</div>
{cafe.planExpiresAt ? (
<p className="text-xs text-muted-foreground">
{t("currentExpiry")}: {new Date(cafe.planExpiresAt).toLocaleDateString("fa-IR")}
</p>
) : null}
</div>
);
}
/**
* Generate / revoke a café's permanent recovery key. The raw key is returned
* once on generate — shown here for copy, never retrievable again.