feat(sase): brand picker — Phase F

Pairs with sase.tr#31 (POST /internal/admin/subscriptions/:id/brands).

Admin SDK
- setSubscriptionBrands({ subscriptionId, brandIds, reason, founderId }).
- BrandSetResult type; SASE_ADMIN_ENDPOINTS list updated.

Repo
- listBrands() helper (RO).
- getUser() now returns currentPlanBrandCount + brandIds for the picker.

Route
- POST /api/sase/subscriptions/[subId]/brands — auth + spoke-wired,
  brandIds array required, reason ≥ 5 chars. Audit on both paths.

UI (BrandPicker on the Billing tab, below subscription actions)
- Full plan (brandCount=0): shows "all brands automatic" badge list,
  no picker.
- Active/trial with brandCount > 0: checkbox grid of all active brands.
  Clicking past the plan limit shows an inline error. Visual deltas:
  outline "kaldırılacak" badge on rows being removed, default "yeni"
  badge on rows being added.
- Cancelled/expired: read-only message.
- Save button enabled only when selection is exactly plan.brandCount,
  there's a dirty diff, and a reason ≥ 5 chars is entered. Saves +
  router.refresh().

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Semih
2026-05-18 10:38:34 +03:00
parent 0eacc32286
commit 096ca9b4a3
5 changed files with 342 additions and 2 deletions

View File

@@ -0,0 +1,69 @@
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { createSaseAdmin, saseAdminWired } from "@/lib/admin-sdk/sase";
import { writeAudit } from "@/lib/audit";
export const dynamic = "force-dynamic";
export async function POST(
req: Request,
ctx: { params: Promise<{ subId: string }> },
) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) {
return NextResponse.json({ ok: false, error: "unauthenticated" }, { status: 401 });
}
if (!saseAdminWired()) {
return NextResponse.json({ ok: false, error: "spoke_not_wired" }, { status: 503 });
}
const { subId } = await ctx.params;
const body = (await req.json().catch(() => ({}))) as {
brandIds?: string[];
reason?: string;
};
if (!Array.isArray(body.brandIds)) {
return NextResponse.json({ ok: false, error: "brandIds_required" }, { status: 400 });
}
const reason = (body.reason ?? "").trim();
if (reason.length < 5) {
return NextResponse.json({ ok: false, error: "reason_required" }, { status: 400 });
}
const endpoint = `/api/sase/subscriptions/${subId}/brands`;
try {
const sdk = createSaseAdmin();
const result = await sdk.setSubscriptionBrands({
subscriptionId: subId,
brandIds: body.brandIds,
reason,
founderId: session.user.id,
});
await writeAudit({
projectKey: "sase",
endpoint,
method: "POST",
requestPayload: {
subId,
brandCount: body.brandIds.length,
reasonLen: reason.length,
},
responseStatus: 200,
});
return NextResponse.json({ ok: true, result });
} catch (err) {
const status = (err as { status?: number } | undefined)?.status ?? 500;
const message = err instanceof Error ? err.message : "unknown";
await writeAudit({
projectKey: "sase",
endpoint,
method: "POST",
requestPayload: { subId, brandCount: body.brandIds.length, reasonLen: reason.length },
responseStatus: status,
});
return NextResponse.json({ ok: false, error: message }, { status });
}
}

View File

@@ -0,0 +1,222 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
} from "@/components/ui/card";
type Brand = { id: string; name: string; slug: string };
export function BrandPicker({
subscriptionId,
subscriptionStatus,
planBrandCount,
currentBrandIds,
allBrands,
}: {
subscriptionId: string;
subscriptionStatus: string;
planBrandCount: number | null;
currentBrandIds: string[];
allBrands: Brand[];
}) {
const router = useRouter();
const [selected, setSelected] = useState<Set<string>>(
new Set(currentBrandIds),
);
const [reason, setReason] = useState("");
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
const canEdit =
(subscriptionStatus === "active" || subscriptionStatus === "trial") &&
planBrandCount !== null &&
planBrandCount > 0;
// Full plan (brandCount = 0) auto-grants all brands; no picker.
if (planBrandCount === 0) {
return (
<Card>
<CardHeader>
<CardDescription>Marka erişimi</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<p>Full plan tüm aktif markalara otomatik erişim ({allBrands.length}).</p>
<div className="flex flex-wrap gap-1">
{allBrands.map((b) => (
<Badge key={b.id} variant="secondary" className="font-mono">
{b.slug}
</Badge>
))}
</div>
</CardContent>
</Card>
);
}
if (!canEdit) {
return (
<Card>
<CardHeader>
<CardDescription>Marka erişimi</CardDescription>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
{subscriptionStatus === "cancelled" || subscriptionStatus === "expired"
? `'${subscriptionStatus}' subscription — düzenlenemez.`
: "Subscription yok ya da plan tanımlı değil."}
</CardContent>
</Card>
);
}
function toggle(id: string) {
setError(null);
setInfo(null);
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
if (next.size >= (planBrandCount ?? 0)) {
setError(`Maksimum ${planBrandCount} marka seçebilirsin. Önce birini çıkar.`);
return prev;
}
next.add(id);
}
return next;
});
}
function submit() {
setError(null);
setInfo(null);
if (selected.size !== planBrandCount) {
setError(
`Tam olarak ${planBrandCount} marka seçilmeli (şu an ${selected.size}).`,
);
return;
}
if (reason.trim().length < 5) {
setError("Sebep en az 5 karakter olmalı.");
return;
}
startTransition(async () => {
const res = await fetch(`/api/sase/subscriptions/${subscriptionId}/brands`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
brandIds: Array.from(selected),
reason: reason.trim(),
}),
});
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
setError(data.error ?? `HTTP ${res.status}`);
return;
}
setInfo("Markalar güncellendi.");
setReason("");
router.refresh();
});
}
const dirty =
selected.size !== currentBrandIds.length ||
Array.from(selected).some((id) => !currentBrandIds.includes(id));
return (
<Card>
<CardHeader>
<CardDescription>
Marka erişimi · seçilen {selected.size}/{planBrandCount}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<ul className="grid grid-cols-2 gap-1 md:grid-cols-3">
{allBrands.map((b) => {
const isSelected = selected.has(b.id);
const isCurrent = currentBrandIds.includes(b.id);
return (
<li key={b.id}>
<label className="flex items-center gap-2 rounded-md border p-2 text-sm hover:bg-muted/30">
<Checkbox
checked={isSelected}
onCheckedChange={() => toggle(b.id)}
/>
<span className="flex-1">{b.name}</span>
<span className="font-mono text-xs text-muted-foreground">
{b.slug}
</span>
{isCurrent && !isSelected && (
<Badge variant="outline" className="text-xs">
kaldırılacak
</Badge>
)}
{!isCurrent && isSelected && (
<Badge variant="default" className="text-xs">
yeni
</Badge>
)}
</label>
</li>
);
})}
</ul>
{dirty && (
<Textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Sebep (zorunlu, audit'e geçer)"
rows={2}
maxLength={500}
/>
)}
<div className="flex items-center justify-between">
<div className="text-xs">
{error && <span className="text-destructive">{error}</span>}
{info && <span className="text-emerald-600">{info}</span>}
</div>
<div className="flex gap-2">
{dirty && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelected(new Set(currentBrandIds));
setReason("");
setError(null);
}}
disabled={pending}
>
Sıfırla
</Button>
)}
<Button
size="sm"
onClick={submit}
disabled={
pending ||
!dirty ||
selected.size !== planBrandCount ||
reason.trim().length < 5
}
>
{pending ? "..." : "Kaydet"}
</Button>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -23,7 +23,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { getUser, listPlans } from "@/lib/sase/users";
import { getUser, listBrands, listPlans } from "@/lib/sase/users";
import {
getUserUsageStats,
getUserTimeline,
@@ -34,6 +34,7 @@ import { NotesTab } from "./_notes";
import { LifecycleButtons, LifecycleStatusBadge } from "./_lifecycle-buttons";
import { BillingActions } from "./_billing-actions";
import { RefundButton } from "./_refund-button";
import { BrandPicker } from "./_brand-picker";
import { saseAdminWired } from "@/lib/admin-sdk/sase";
export const dynamic = "force-dynamic";
@@ -47,11 +48,12 @@ export default async function SaseUserDetailPage({
const user = await getUser(id);
if (!user) notFound();
const [usage, timeline, audit, plans] = await Promise.all([
const [usage, timeline, audit, plans, brands] = await Promise.all([
getUserUsageStats(id),
getUserTimeline(id, 80),
getUserAuditTrail(id, 50),
listPlans(),
listBrands(),
]);
return (
@@ -188,6 +190,16 @@ export default async function SaseUserDetailPage({
</CardContent>
</Card>
{saseAdminWired() && user.subscriptionId && user.subscriptionStatus && (
<BrandPicker
subscriptionId={user.subscriptionId}
subscriptionStatus={user.subscriptionStatus}
planBrandCount={user.currentPlanBrandCount}
currentBrandIds={user.brandIds}
allBrands={brands}
/>
)}
<div className="rounded-md border">
<Table>
<TableHeader>

View File

@@ -101,6 +101,23 @@ export type SaseAdmin = {
reason: string;
founderId: string;
}): Promise<RefundResult>;
/** Replace the brand set on an active/trial subscription. */
setSubscriptionBrands(input: {
subscriptionId: string;
brandIds: string[];
reason: string;
founderId: string;
}): Promise<BrandSetResult>;
};
export type BrandSetResult = {
success: boolean;
subscriptionId: string;
userId: string;
planBrandCount: number;
previousBrandIds: string[];
newBrandIds: string[];
};
export type RefundResult = {
@@ -233,6 +250,12 @@ export function createSaseAdmin(): SaseAdmin {
reason: input.reason,
founderId: input.founderId,
}),
setSubscriptionBrands: (input) =>
client.call("POST", `/internal/admin/subscriptions/${input.subscriptionId}/brands`, {
brandIds: input.brandIds,
reason: input.reason,
founderId: input.founderId,
}),
};
}
@@ -256,6 +279,7 @@ function notWiredSdk(projectKey: string): SaseAdmin {
cancelSubscriptionById: () => Promise.reject(reject()),
resumeSubscription: () => Promise.reject(reject()),
refundPayment: () => Promise.reject(reject()),
setSubscriptionBrands: () => Promise.reject(reject()),
};
}
@@ -277,4 +301,5 @@ export const SASE_ADMIN_ENDPOINTS = [
"POST /internal/admin/subscriptions/:id/cancel",
"POST /internal/admin/subscriptions/:id/resume",
"POST /internal/admin/payments/:id/refund",
"POST /internal/admin/subscriptions/:id/brands",
];

View File

@@ -189,6 +189,8 @@ export type UserDetail = UserRow & {
statusChangedAt: Date | null;
subscriptionId: string | null;
currentPlanId: string | null;
currentPlanBrandCount: number | null;
brandIds: string[];
payments: Array<{
id: string;
amount: number;
@@ -244,6 +246,8 @@ export async function getUser(id: string): Promise<UserDetail | null> {
statusChangedAt: u.statusChangedAt,
subscriptionId: sub?.id ?? null,
currentPlanId: sub?.planId ?? null,
currentPlanBrandCount: sub?.plan.brandCount ?? null,
brandIds: sub?.brands.map((b) => b.brandId) ?? [],
planName: sub?.plan.name ?? null,
planTier: tierFromPlan(sub?.plan.name ?? null, sub?.plan.brandCount ?? null),
subscriptionStatus: sub?.status ?? null,
@@ -271,6 +275,14 @@ export async function listPlans() {
});
}
export async function listBrands() {
return saseDb.brand.findMany({
where: { isActive: true },
orderBy: { name: "asc" },
select: { id: true, name: true, slug: true },
});
}
function tierFromPlan(name: string | null, brandCount: number | null): string | null {
if (!name) return null;
const n = name.toLowerCase();