feat: plan/limit system + role-based page access

Plan & limit enforcement:
- src/lib/plans.ts: PLAN_LIMITS (free: 15 props, 25 customers, 5 presentations, 1 member, 5 imgs/prop)
- checkLimit() + limitErrorMessage() — role-aware error messages (owner: upgrade CTA, others: contact admin)
- Create actions (property, customer, presentation, team invite): hard limit check before create
- PropertyImageUploader: maxImages prop + isOwner-aware toast
- UsageBadge component: usage counter shown only to owner (green→amber→red)

Role-based access:
- TenantContext + ActiveContext: add memberCount + role fields
- Dashboard layout: non-owner on free plan with >1 member → /plan-limit
- /plan-limit: blocked-access page with owner contact info + sign-out
- AppSidebar: minRole filtering — Plan & Faturalama (owner only), Çalışma Alanı/Yatırımcılar (admin+)
- settings/billing: owner-only hard redirect
- settings/workspace + settings/members: member redirect, admin read-only
- settings/investors: member redirect
- workspace-actions + logo-actions: restricted to owner only
- Workspace form: canEdit = owner only (admin sees read-only view)
This commit is contained in:
egecankomur
2026-05-12 18:46:02 +03:00
parent 7c23a2b4ae
commit 933cb17107
27 changed files with 475 additions and 44 deletions
+13 -2
View File
@@ -3,14 +3,25 @@ export const dynamic = "force-dynamic";
import { requireTenant } from "@/lib/appwrite/tenant-guard"; import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { listCustomers } from "@/lib/appwrite/customer-queries"; import { listCustomers } from "@/lib/appwrite/customer-queries";
import { CustomersClient } from "@/components/customers/customers-client"; import { CustomersClient } from "@/components/customers/customers-client";
import { checkLimit, PLAN_LIMITS } from "@/lib/plans";
export default async function CustomersPage() { export default async function CustomersPage() {
const ctx = await requireTenant(); const ctx = await requireTenant();
const customers = await listCustomers(ctx.tenantId); const [customers, limitResult] = await Promise.all([
listCustomers(ctx.tenantId),
checkLimit(ctx.tenantId, ctx.settings?.plan, "customers"),
]);
const plan = ctx.settings?.plan ?? "free";
const limit = PLAN_LIMITS[plan].customers;
return ( return (
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6"> <div className="flex flex-1 flex-col gap-4 p-4 md:p-6">
<CustomersClient initialCustomers={customers} /> <CustomersClient
initialCustomers={customers}
isOwner={ctx.role === "owner"}
usageLimit={limit !== Infinity ? { current: limitResult.current, limit } : undefined}
/>
</div> </div>
); );
} }
+6
View File
@@ -24,18 +24,22 @@ export type ShellCompany = {
logoUrl?: string | null; logoUrl?: string | null;
}; };
export type ShellRole = "owner" | "admin" | "member";
export function DashboardShell({ export function DashboardShell({
user, user,
company, company,
children, children,
initialPrefs, initialPrefs,
pendingMatchCount = 0, pendingMatchCount = 0,
role = "member",
}: { }: {
user: ShellUser; user: ShellUser;
company: ShellCompany; company: ShellCompany;
children: React.ReactNode; children: React.ReactNode;
initialPrefs: ThemePrefs; initialPrefs: ThemePrefs;
pendingMatchCount?: number; pendingMatchCount?: number;
role?: ShellRole;
}) { }) {
const [themeCustomizerOpen, setThemeCustomizerOpen] = React.useState(false); const [themeCustomizerOpen, setThemeCustomizerOpen] = React.useState(false);
const { config } = useSidebarConfig(); const { config } = useSidebarConfig();
@@ -63,6 +67,7 @@ export function DashboardShell({
collapsible={config.collapsible} collapsible={config.collapsible}
side={config.side} side={config.side}
pendingMatchCount={pendingMatchCount} pendingMatchCount={pendingMatchCount}
role={role}
/> />
<SidebarInset> <SidebarInset>
<SiteHeader company={company} /> <SiteHeader company={company} />
@@ -84,6 +89,7 @@ export function DashboardShell({
collapsible={config.collapsible} collapsible={config.collapsible}
side={config.side} side={config.side}
pendingMatchCount={pendingMatchCount} pendingMatchCount={pendingMatchCount}
role={role}
/> />
</> </>
)} )}
+2
View File
@@ -1,5 +1,6 @@
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
import { redirect } from "next/navigation";
import { Query } from "node-appwrite"; import { Query } from "node-appwrite";
import { requireTenant } from "@/lib/appwrite/tenant-guard"; import { requireTenant } from "@/lib/appwrite/tenant-guard";
@@ -9,6 +10,7 @@ import { InvestorsClient } from "@/components/investors/investors-client";
export default async function InvestorsPage() { export default async function InvestorsPage() {
const ctx = await requireTenant(); const ctx = await requireTenant();
if (ctx.role === "member") redirect("/dashboard");
const { tablesDB } = createAdminClient(); const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({ const result = await tablesDB.listRows({
+9 -1
View File
@@ -6,6 +6,7 @@ import { getLogoUrl } from "@/lib/appwrite/storage";
import { createAdminClient, createSessionClient, getCurrentUser } from "@/lib/appwrite/server"; import { createAdminClient, createSessionClient, getCurrentUser } from "@/lib/appwrite/server";
import { DATABASE_ID, TABLES } from "@/lib/appwrite/schema"; import { DATABASE_ID, TABLES } from "@/lib/appwrite/schema";
import type { ThemePrefs } from "@/lib/appwrite/theme-prefs-actions"; import type { ThemePrefs } from "@/lib/appwrite/theme-prefs-actions";
import { PLAN_LIMITS } from "@/lib/plans";
import { DashboardShell } from "./dashboard-shell"; import { DashboardShell } from "./dashboard-shell";
export default async function DashboardLayout({ export default async function DashboardLayout({
@@ -19,6 +20,13 @@ export default async function DashboardLayout({
const ctx = await getActiveContext(); const ctx = await getActiveContext();
if (!ctx) redirect("/onboarding"); if (!ctx) redirect("/onboarding");
// Hard plan enforcement: non-owner members on free plan with more than 1 confirmed member
const plan = ctx.settings?.plan ?? "free";
const memberLimit = PLAN_LIMITS[plan].teamMembers;
if (ctx.role !== "owner" && memberLimit !== Infinity && ctx.memberCount > memberLimit) {
redirect("/plan-limit");
}
let pendingMatchCount = 0; let pendingMatchCount = 0;
try { try {
const { tablesDB } = createAdminClient(); const { tablesDB } = createAdminClient();
@@ -53,7 +61,7 @@ export default async function DashboardLayout({
}; };
return ( return (
<DashboardShell user={user} company={company} initialPrefs={themePrefs} pendingMatchCount={pendingMatchCount}> <DashboardShell user={user} company={company} initialPrefs={themePrefs} pendingMatchCount={pendingMatchCount} role={ctx.role}>
{children} {children}
</DashboardShell> </DashboardShell>
); );
+7 -1
View File
@@ -8,12 +8,13 @@ import { listProperties } from "@/lib/appwrite/property-queries";
import { DATABASE_ID, TABLES, type Presentation } from "@/lib/appwrite/schema"; import { DATABASE_ID, TABLES, type Presentation } from "@/lib/appwrite/schema";
import { createAdminClient } from "@/lib/appwrite/server"; import { createAdminClient } from "@/lib/appwrite/server";
import { PresentationsClient } from "@/components/presentations/presentations-client"; import { PresentationsClient } from "@/components/presentations/presentations-client";
import { checkLimit, PLAN_LIMITS } from "@/lib/plans";
export default async function PresentationsPage() { export default async function PresentationsPage() {
const ctx = await requireTenant(); const ctx = await requireTenant();
const { tablesDB } = createAdminClient(); const { tablesDB } = createAdminClient();
const [customers, properties, presResult] = await Promise.all([ const [customers, properties, presResult, limitResult] = await Promise.all([
listCustomers(ctx.tenantId), listCustomers(ctx.tenantId),
listProperties(ctx.tenantId), listProperties(ctx.tenantId),
tablesDB.listRows({ tablesDB.listRows({
@@ -25,12 +26,15 @@ export default async function PresentationsPage() {
Query.limit(200), Query.limit(200),
], ],
}), }),
checkLimit(ctx.tenantId, ctx.settings?.plan, "presentations"),
]).catch((e) => { ]).catch((e) => {
console.error("[PresentationsPage] data fetch failed:", e); console.error("[PresentationsPage] data fetch failed:", e);
throw e; throw e;
}); });
const presentations = JSON.parse(JSON.stringify(presResult.rows)) as Presentation[]; const presentations = JSON.parse(JSON.stringify(presResult.rows)) as Presentation[];
const plan = ctx.settings?.plan ?? "free";
const presLimit = PLAN_LIMITS[plan].presentations;
return ( return (
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6"> <div className="flex flex-1 flex-col gap-4 p-4 md:p-6">
@@ -38,6 +42,8 @@ export default async function PresentationsPage() {
initialPresentations={presentations} initialPresentations={presentations}
customers={customers} customers={customers}
properties={properties} properties={properties}
isOwner={ctx.role === "owner"}
usageLimit={presLimit !== Infinity ? { current: limitResult.current, limit: presLimit } : undefined}
/> />
</div> </div>
); );
+16 -2
View File
@@ -3,14 +3,28 @@ export const dynamic = "force-dynamic";
import { requireTenant } from "@/lib/appwrite/tenant-guard"; import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { listProperties } from "@/lib/appwrite/property-queries"; import { listProperties } from "@/lib/appwrite/property-queries";
import { PropertiesClient } from "@/components/properties/properties-client"; import { PropertiesClient } from "@/components/properties/properties-client";
import { checkLimit, PLAN_LIMITS } from "@/lib/plans";
export default async function PropertiesPage() { export default async function PropertiesPage() {
const ctx = await requireTenant(); const ctx = await requireTenant();
const properties = await listProperties(ctx.tenantId); const [properties, limitResult] = await Promise.all([
listProperties(ctx.tenantId),
checkLimit(ctx.tenantId, ctx.settings?.plan, "properties"),
]);
const plan = ctx.settings?.plan ?? "free";
const limit = PLAN_LIMITS[plan].properties;
const imgLimit = PLAN_LIMITS[plan].propertyImages;
return ( return (
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6 overflow-hidden"> <div className="flex flex-1 flex-col gap-4 p-4 md:p-6 overflow-hidden">
<PropertiesClient initialProperties={properties} /> <PropertiesClient
initialProperties={properties}
isOwner={ctx.role === "owner"}
usageLimit={limit !== Infinity ? { current: limitResult.current, limit } : undefined}
maxImagesPerProperty={imgLimit !== Infinity ? imgLimit : undefined}
/>
</div> </div>
); );
} }
@@ -25,6 +25,8 @@ export default async function BillingPage({
redirect("/onboarding"); redirect("/onboarding");
} }
if (ctx.role !== "owner") redirect("/dashboard");
const params = await searchParams; const params = await searchParams;
const upgraded = params.upgraded === "1"; const upgraded = params.upgraded === "1";
const downgraded = params.downgraded === "1"; const downgraded = params.downgraded === "1";
@@ -23,6 +23,8 @@ export default async function MembersPage() {
redirect("/onboarding"); redirect("/onboarding");
} }
if (ctx.role === "member") redirect("/dashboard");
const canManage = ctx.role === "owner" || ctx.role === "admin"; const canManage = ctx.role === "owner" || ctx.role === "admin";
const isOwner = ctx.role === "owner"; const isOwner = ctx.role === "owner";
@@ -20,7 +20,9 @@ export default async function WorkspaceSettingsPage() {
redirect("/onboarding"); redirect("/onboarding");
} }
const canEdit = ctx.role === "owner" || ctx.role === "admin"; if (ctx.role === "member") redirect("/dashboard");
const canEdit = ctx.role === "owner";
const officeName = ctx.settings?.officeName ?? "Ofis"; const officeName = ctx.settings?.officeName ?? "Ofis";
return ( return (
+99
View File
@@ -0,0 +1,99 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { getCurrentUser, createAdminClient } from "@/lib/appwrite/server";
import { getActiveTenantId } from "@/lib/appwrite/tenant";
import { DATABASE_ID, TABLES, type TenantSettings } from "@/lib/appwrite/schema";
import { PLAN_LIMITS } from "@/lib/plans";
import { Query } from "node-appwrite";
import { SignOutForm } from "./sign-out-form";
export default async function PlanLimitPage() {
const user = await getCurrentUser();
if (!user) redirect("/sign-in");
const tenantId = await getActiveTenantId();
if (!tenantId) redirect("/onboarding");
const { tablesDB, teams } = createAdminClient();
const [settingsResult, membershipsResult] = await Promise.all([
tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.tenantSettings,
queries: [Query.equal("tenantId", tenantId), Query.limit(1)],
}),
teams.listMemberships(tenantId),
]);
const settings = settingsResult.rows[0] as unknown as TenantSettings | undefined;
const plan = settings?.plan ?? "free";
const memberLimit = PLAN_LIMITS[plan].teamMembers;
// If user is owner or plan allows them, redirect back to dashboard
const myMembership = membershipsResult.memberships.find((m) => m.userId === user.$id);
const isOwner = myMembership?.roles?.includes("owner");
const confirmedCount = membershipsResult.memberships.filter((m) => m.confirm).length;
if (isOwner || memberLimit === Infinity || confirmedCount <= memberLimit) {
redirect("/dashboard");
}
// Find the owner to show their contact
const ownerMembership = membershipsResult.memberships.find((m) => m.roles?.includes("owner"));
return (
<div className="min-h-dvh flex items-center justify-center bg-muted/30 p-4">
<div className="w-full max-w-md rounded-2xl border bg-card shadow-sm overflow-hidden">
{/* Header */}
<div className="bg-gradient-to-br from-slate-800 to-slate-900 px-8 py-8 text-center">
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-white/10">
<svg className="size-8 text-amber-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2zm0 15a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm1-4a1 1 0 0 1-2 0V7a1 1 0 0 1 2 0v6z" />
</svg>
</div>
<h1 className="text-xl font-bold text-white">Erişim Kısıtlandı</h1>
<p className="mt-1 text-sm text-slate-400">
{settings?.officeName ?? "Bu çalışma alanı"} ücretsiz plana geçti
</p>
</div>
{/* Body */}
<div className="px-8 py-6 space-y-4">
<p className="text-sm text-muted-foreground leading-relaxed">
Ücretsiz planda yalnızca <strong>1 kullanıcı</strong> sisteme erişebilir.
Bu çalışma alanındaki erişiminiz geçici olarak askıya alınmıştır.
</p>
<div className="rounded-lg border bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-800 px-4 py-3 text-sm text-amber-800 dark:text-amber-300">
Yöneticinizden çalışma alanını Pro plana yükseltmesini isteyin ya da
hesabınızı çalışma alanından çıkarmasını talep edin.
</div>
{ownerMembership?.userEmail && (
<div className="rounded-lg border px-4 py-3 text-sm">
<p className="text-muted-foreground text-xs mb-1">Çalışma alanı yöneticisi</p>
<p className="font-medium">{ownerMembership.userName ?? ownerMembership.userEmail}</p>
<a
href={`mailto:${ownerMembership.userEmail}`}
className="text-xs text-primary hover:underline"
>
{ownerMembership.userEmail}
</a>
</div>
)}
</div>
{/* Footer */}
<div className="border-t px-8 py-4 flex items-center justify-between gap-3 bg-muted/20">
<SignOutForm />
<Link
href="https://kovakcrm.com/fiyatlar"
className="text-xs text-muted-foreground hover:text-foreground underline-offset-2 hover:underline"
>
Plan karşılaştır
</Link>
</div>
</div>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
"use client";
import { useTransition } from "react";
import { signOutAction } from "@/lib/appwrite/auth-actions";
export function SignOutForm() {
const [isPending, startTransition] = useTransition();
function handleSignOut() {
startTransition(async () => {
await signOutAction();
});
}
return (
<button
type="button"
onClick={handleSignOut}
disabled={isPending}
className="text-sm text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
>
{isPending ? "Çıkış yapılıyor..." : "Çıkış yap"}
</button>
);
}
+36 -7
View File
@@ -30,9 +30,26 @@ import {
SidebarMenuItem, SidebarMenuItem,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import type { ShellCompany, ShellUser } from "@/app/(dashboard)/dashboard-shell"; import type { Icon } from "@/lib/icons";
import type { ShellCompany, ShellRole, ShellUser } from "@/app/(dashboard)/dashboard-shell";
const navGroups = [ type NavItem = {
title: string;
url: string;
icon?: Icon;
items?: { title: string; url: string; badge?: number }[];
minRole?: ShellRole;
};
type NavGroup = { label: string; items: NavItem[] };
const ROLE_RANK: Record<ShellRole, number> = { owner: 3, admin: 2, member: 1 };
function hasAccess(minRole: ShellRole | undefined, userRole: ShellRole): boolean {
if (!minRole) return true;
return ROLE_RANK[userRole] >= ROLE_RANK[minRole];
}
const navGroups: NavGroup[] = [
{ {
label: "Genel", label: "Genel",
items: [ items: [
@@ -70,6 +87,7 @@ const navGroups = [
title: "Yatırımcılar", title: "Yatırımcılar",
url: "/investors", url: "/investors",
icon: Wallet, icon: Wallet,
minRole: "admin" as ShellRole,
}, },
], ],
}, },
@@ -100,6 +118,7 @@ const navGroups = [
title: "Çalışma Alanı", title: "Çalışma Alanı",
url: "/settings/workspace", url: "/settings/workspace",
icon: GearSix, icon: GearSix,
minRole: "admin" as ShellRole,
items: [ items: [
{ title: "Ofis Bilgileri", url: "/settings/workspace" }, { title: "Ofis Bilgileri", url: "/settings/workspace" },
{ title: "Ekip Üyeleri", url: "/settings/members" }, { title: "Ekip Üyeleri", url: "/settings/members" },
@@ -114,6 +133,7 @@ const navGroups = [
title: "Plan & Faturalama", title: "Plan & Faturalama",
url: "/settings/billing", url: "/settings/billing",
icon: CreditCard, icon: CreditCard,
minRole: "owner" as ShellRole,
}, },
], ],
}, },
@@ -123,16 +143,20 @@ export function AppSidebar({
user, user,
company, company,
pendingMatchCount = 0, pendingMatchCount = 0,
role = "member",
...props ...props
}: React.ComponentProps<typeof Sidebar> & { }: React.ComponentProps<typeof Sidebar> & {
user: ShellUser; user: ShellUser;
company: ShellCompany; company: ShellCompany;
pendingMatchCount?: number; pendingMatchCount?: number;
role?: ShellRole;
}) { }) {
// Inject badge into the Eşleşmeler sub-item const groups = navGroups
const groups = navGroups.map((group) => ({ .map((group) => ({
...group, ...group,
items: group.items.map((item) => ({ items: group.items
.filter((item) => hasAccess(item.minRole, role))
.map((item) => ({
...item, ...item,
items: item.items?.map((sub) => items: item.items?.map((sub) =>
sub.url === "/customers/matches" && pendingMatchCount > 0 sub.url === "/customers/matches" && pendingMatchCount > 0
@@ -140,7 +164,8 @@ export function AppSidebar({
: sub, : sub,
), ),
})), })),
})); }))
.filter((group) => group.items.length > 0);
return ( return (
<Sidebar {...props}> <Sidebar {...props}>
@@ -174,7 +199,11 @@ export function AppSidebar({
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
{groups.map((group) => ( {groups.map((group) => (
<NavMain key={group.label} label={group.label} items={group.items} /> <NavMain
key={group.label}
label={group.label}
items={group.items.map(({ minRole: _minRole, ...rest }) => rest)}
/>
))} ))}
</SidebarContent> </SidebarContent>
<SidebarFooter> <SidebarFooter>
@@ -18,6 +18,7 @@ import { deleteCustomerAction } from "@/lib/appwrite/customer-actions";
import { CustomerFormSheet } from "./customer-form-sheet"; import { CustomerFormSheet } from "./customer-form-sheet";
import { CustomersPipeline } from "./customers-pipeline"; import { CustomersPipeline } from "./customers-pipeline";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog"; import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import { UsageBadge } from "@/components/ui/usage-badge";
import type { Customer, CustomerType, CustomerStage } from "@/lib/appwrite/schema"; import type { Customer, CustomerType, CustomerStage } from "@/lib/appwrite/schema";
import { import {
CUSTOMER_TYPE_LABELS, CUSTOMER_TYPE_LABELS,
@@ -29,9 +30,11 @@ type ViewMode = "list" | "pipeline";
interface CustomersClientProps { interface CustomersClientProps {
initialCustomers: Customer[]; initialCustomers: Customer[];
isOwner?: boolean;
usageLimit?: { current: number; limit: number };
} }
export function CustomersClient({ initialCustomers }: CustomersClientProps) { export function CustomersClient({ initialCustomers, isOwner, usageLimit }: CustomersClientProps) {
const router = useRouter(); const router = useRouter();
const [customers, setCustomers] = useState(initialCustomers); const [customers, setCustomers] = useState(initialCustomers);
const [sheetOpen, setSheetOpen] = useState(false); const [sheetOpen, setSheetOpen] = useState(false);
@@ -79,7 +82,10 @@ export function CustomersClient({ initialCustomers }: CustomersClientProps) {
{/* Header */} {/* Header */}
<div className="flex items-center justify-between gap-3 flex-wrap"> <div className="flex items-center justify-between gap-3 flex-wrap">
<h1 className="text-2xl font-bold">Müşteriler</h1> <h1 className="text-2xl font-bold">Müşteriler</h1>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-wrap">
{isOwner && usageLimit && (
<UsageBadge current={usageLimit.current} limit={usageLimit.limit} label="müşteri" />
)}
{/* View toggle */} {/* View toggle */}
<div className="flex rounded-md border overflow-hidden"> <div className="flex rounded-md border overflow-hidden">
<button <button
@@ -15,18 +15,23 @@ import {
import { deletePresentationAction } from "@/lib/appwrite/presentation-actions"; import { deletePresentationAction } from "@/lib/appwrite/presentation-actions";
import { PresentationFormSheet } from "./presentation-form-sheet"; import { PresentationFormSheet } from "./presentation-form-sheet";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog"; import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import { UsageBadge } from "@/components/ui/usage-badge";
import type { Customer, Presentation, Property } from "@/lib/appwrite/schema"; import type { Customer, Presentation, Property } from "@/lib/appwrite/schema";
interface PresentationsClientProps { interface PresentationsClientProps {
initialPresentations: Presentation[]; initialPresentations: Presentation[];
customers: Customer[]; customers: Customer[];
properties: Property[]; properties: Property[];
isOwner?: boolean;
usageLimit?: { current: number; limit: number };
} }
export function PresentationsClient({ export function PresentationsClient({
initialPresentations, initialPresentations,
customers, customers,
properties, properties,
isOwner,
usageLimit,
}: PresentationsClientProps) { }: PresentationsClientProps) {
const router = useRouter(); const router = useRouter();
const [presentations, setPresentations] = useState(initialPresentations); const [presentations, setPresentations] = useState(initialPresentations);
@@ -86,13 +91,18 @@ export function PresentationsClient({
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-3 flex-wrap">
<h1 className="text-2xl font-bold">Sunumlar</h1> <h1 className="text-2xl font-bold">Sunumlar</h1>
<div className="flex items-center gap-2">
{isOwner && usageLimit && (
<UsageBadge current={usageLimit.current} limit={usageLimit.limit} label="sunum" />
)}
<Button onClick={openCreate} size="sm" data-tour="presentations-add"> <Button onClick={openCreate} size="sm" data-tour="presentations-add">
<Plus className="mr-1.5 size-4" /> <Plus className="mr-1.5 size-4" />
Yeni Sunum Yeni Sunum
</Button> </Button>
</div> </div>
</div>
<div data-tour="presentations-table" className="rounded-md border"> <div data-tour="presentations-table" className="rounded-md border">
<Table> <Table>
@@ -20,6 +20,7 @@ import { ImageLightbox } from "./image-lightbox";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog"; import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import { PropertiesMapView } from "@/components/map/properties-map-view"; import { PropertiesMapView } from "@/components/map/properties-map-view";
import { getPropertyImagePreviewUrl, parseImageIds } from "@/lib/appwrite/storage-utils"; import { getPropertyImagePreviewUrl, parseImageIds } from "@/lib/appwrite/storage-utils";
import { UsageBadge } from "@/components/ui/usage-badge";
import type { Property, PropertyStatus } from "@/lib/appwrite/schema"; import type { Property, PropertyStatus } from "@/lib/appwrite/schema";
import { import {
PROPERTY_STATUS_LABELS, PROPERTY_STATUS_LABELS,
@@ -29,11 +30,14 @@ import {
interface PropertiesClientProps { interface PropertiesClientProps {
initialProperties: Property[]; initialProperties: Property[];
isOwner?: boolean;
usageLimit?: { current: number; limit: number };
maxImagesPerProperty?: number;
} }
type ViewMode = "list" | "gallery" | "map"; type ViewMode = "list" | "gallery" | "map";
export function PropertiesClient({ initialProperties }: PropertiesClientProps) { export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxImagesPerProperty }: PropertiesClientProps) {
const router = useRouter(); const router = useRouter();
const [properties, setProperties] = useState(initialProperties); const [properties, setProperties] = useState(initialProperties);
const [sheetOpen, setSheetOpen] = useState(false); const [sheetOpen, setSheetOpen] = useState(false);
@@ -125,7 +129,10 @@ export function PropertiesClient({ initialProperties }: PropertiesClientProps) {
</p> </p>
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-wrap">
{isOwner && usageLimit && (
<UsageBadge current={usageLimit.current} limit={usageLimit.limit} label="ilan" />
)}
{/* View toggle */} {/* View toggle */}
<div className="flex rounded-md border overflow-hidden"> <div className="flex rounded-md border overflow-hidden">
{([ {([
@@ -397,6 +404,8 @@ export function PropertiesClient({ initialProperties }: PropertiesClientProps) {
onOpenChange={setSheetOpen} onOpenChange={setSheetOpen}
property={editing} property={editing}
onSuccess={() => router.refresh()} onSuccess={() => router.refresh()}
maxImages={maxImagesPerProperty}
isOwner={isOwner}
/> />
<DeleteConfirmDialog <DeleteConfirmDialog
open={!!deleteTarget} open={!!deleteTarget}
@@ -34,9 +34,11 @@ interface PropertyFormSheetProps {
onOpenChange: (v: boolean) => void; onOpenChange: (v: boolean) => void;
property?: Property | null; property?: Property | null;
onSuccess?: () => void; onSuccess?: () => void;
maxImages?: number;
isOwner?: boolean;
} }
export function PropertyFormSheet({ open, onOpenChange, property, onSuccess }: PropertyFormSheetProps) { export function PropertyFormSheet({ open, onOpenChange, property, onSuccess, maxImages, isOwner }: PropertyFormSheetProps) {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const action = property ? updatePropertyAction.bind(null, property.$id) : createPropertyAction; const action = property ? updatePropertyAction.bind(null, property.$id) : createPropertyAction;
@@ -207,7 +209,7 @@ export function PropertyFormSheet({ open, onOpenChange, property, onSuccess }: P
</div> </div>
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<Label>Fotoğraflar</Label> <Label>Fotoğraflar</Label>
<PropertyImageUploader name="imageIds" initialImageIds={parseImageIds(property?.imageIds)} /> <PropertyImageUploader name="imageIds" initialImageIds={parseImageIds(property?.imageIds)} maxImages={maxImages} isOwner={isOwner} />
</div> </div>
</> </>
), ),
@@ -15,9 +15,11 @@ interface PropertyImageUploaderProps {
name: string; name: string;
initialImageIds?: string[]; initialImageIds?: string[];
onChangeIds?: (ids: string[]) => void; onChangeIds?: (ids: string[]) => void;
maxImages?: number;
isOwner?: boolean;
} }
export function PropertyImageUploader({ name, initialImageIds = [], onChangeIds }: PropertyImageUploaderProps) { export function PropertyImageUploader({ name, initialImageIds = [], onChangeIds, maxImages, isOwner }: PropertyImageUploaderProps) {
const [imageIds, setImageIds] = useState<string[]>(initialImageIds); const [imageIds, setImageIds] = useState<string[]>(initialImageIds);
const [queue, setQueue] = useState<UploadingFile[]>([]); const [queue, setQueue] = useState<UploadingFile[]>([]);
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
@@ -40,6 +42,14 @@ export function PropertyImageUploader({ name, initialImageIds = [], onChangeIds
toast.error(`${file.name}: 50 MB sınırını aşıyor.`); toast.error(`${file.name}: 50 MB sınırını aşıyor.`);
return; return;
} }
if (maxImages !== undefined && imageIds.length >= maxImages) {
toast.error(
isOwner
? `Ücretsiz planda ilan başına en fazla ${maxImages} fotoğraf ekleyebilirsiniz. Pro'ya geçerek limitsiz yükleyin.`
: "Bu çalışma alanı fotoğraf limitine ulaştı. Yöneticinizle iletişime geçin.",
);
return;
}
const uid = crypto.randomUUID(); const uid = crypto.randomUUID();
setQueue((q) => [...q, { uid, name: file.name, progress: 0, phase: "compressing" }]); setQueue((q) => [...q, { uid, name: file.name, progress: 0, phase: "compressing" }]);
@@ -94,7 +104,7 @@ export function PropertyImageUploader({ name, initialImageIds = [], onChangeIds
xhr.open("POST", "/api/properties/images"); xhr.open("POST", "/api/properties/images");
xhr.send(fd); xhr.send(fd);
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, [imageIds, maxImages]); // eslint-disable-line react-hooks/exhaustive-deps
const handleFiles = useCallback((files: FileList | null) => { const handleFiles = useCallback((files: FileList | null) => {
if (!files) return; if (!files) return;
+54
View File
@@ -0,0 +1,54 @@
"use client";
import { Crown } from "@/lib/icons";
import type { LimitKey } from "@/lib/plans";
interface UsageBadgeProps {
current: number;
limit: number;
label: string;
limitKey?: LimitKey;
}
export function UsageBadge({ current, limit, label }: UsageBadgeProps) {
if (limit === Infinity || limit <= 0) return null;
const pct = current / limit;
const isWarning = pct >= 0.8;
const isFull = current >= limit;
return (
<span
className={`inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full border font-medium ${
isFull
? "bg-destructive/10 text-destructive border-destructive/20"
: isWarning
? "bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950/30 dark:text-amber-400 dark:border-amber-800"
: "bg-muted text-muted-foreground border-transparent"
}`}
title={`${current}/${limit} ${label}`}
>
{isFull && <Crown className="size-3" weight="fill" />}
{current}/{limit} {label}
</span>
);
}
interface UpgradeBannerProps {
message: string;
}
export function UpgradeBanner({ message }: UpgradeBannerProps) {
return (
<div className="flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-4 py-2.5 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
<Crown className="size-4 shrink-0 text-amber-500" weight="fill" />
<span className="flex-1">{message}</span>
<a
href="/dashboard/settings?tab=plan"
className="whitespace-nowrap font-semibold text-amber-700 underline-offset-2 hover:underline dark:text-amber-400"
>
Pro&apos;ya Geç
</a>
</div>
);
}
+22 -3
View File
@@ -8,10 +8,18 @@ import { DATABASE_ID, TABLES, type TenantSettings } from "./schema";
import { ACTIVE_TENANT_COOKIE } from "./tenant-types"; import { ACTIVE_TENANT_COOKIE } from "./tenant-types";
import { getActiveTenantId } from "./tenant"; import { getActiveTenantId } from "./tenant";
function pickHighestRole(roles: string[]): "owner" | "admin" | "member" {
if (roles.includes("owner")) return "owner";
if (roles.includes("admin")) return "admin";
return "member";
}
export type ActiveContext = { export type ActiveContext = {
user: { id: string; name: string; email: string }; user: { id: string; name: string; email: string };
tenantId: string; tenantId: string;
settings: TenantSettings | null; settings: TenantSettings | null;
role: "owner" | "admin" | "member";
memberCount: number;
}; };
async function setActiveTenantCookie(tenantId: string) { async function setActiveTenantCookie(tenantId: string) {
@@ -72,13 +80,22 @@ export async function getActiveContext(): Promise<ActiveContext | null> {
if (!tenantId) return null; if (!tenantId) return null;
let settings: TenantSettings | null = null; let settings: TenantSettings | null = null;
let role: "owner" | "admin" | "member" = "member";
let memberCount = 1;
try { try {
const result = await tablesDB.listRows({ const [settingsResult, membershipsResult] = await Promise.all([
tablesDB.listRows({
databaseId: DATABASE_ID, databaseId: DATABASE_ID,
tableId: TABLES.tenantSettings, tableId: TABLES.tenantSettings,
queries: [Query.equal("tenantId", tenantId), Query.limit(1)], queries: [Query.equal("tenantId", tenantId), Query.limit(1)],
}); }),
settings = (result.rows[0] as unknown as TenantSettings) ?? null; adminTeams.listMemberships(tenantId),
]);
settings = (settingsResult.rows[0] as unknown as TenantSettings) ?? null;
const myMembership = membershipsResult.memberships.find((m) => m.userId === user.$id);
if (myMembership) role = pickHighestRole(myMembership.roles);
memberCount = membershipsResult.memberships.filter((m) => m.confirm).length;
} catch { } catch {
settings = null; settings = null;
} }
@@ -87,5 +104,7 @@ export async function getActiveContext(): Promise<ActiveContext | null> {
user: { id: user.$id, name: user.name, email: user.email }, user: { id: user.$id, name: user.name, email: user.email },
tenantId, tenantId,
settings, settings,
role,
memberCount,
}; };
} }
+6
View File
@@ -7,6 +7,7 @@ import { customerSchema } from "@/lib/validation/customers";
import { DATABASE_ID, TABLES, type CustomerStage } from "./schema"; import { DATABASE_ID, TABLES, type CustomerStage } from "./schema";
import { createAdminClient } from "./server"; import { createAdminClient } from "./server";
import { requireTenant } from "@/lib/appwrite/tenant-guard"; import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { checkLimit, limitErrorMessage } from "@/lib/plans";
type ActionState = { ok: boolean; error?: string; fieldErrors?: Record<string, string[]> }; type ActionState = { ok: boolean; error?: string; fieldErrors?: Record<string, string[]> };
@@ -21,6 +22,11 @@ export async function createCustomerAction(
return { ok: false, fieldErrors: parsed.error.flatten().fieldErrors }; return { ok: false, fieldErrors: parsed.error.flatten().fieldErrors };
} }
const limitCheck = await checkLimit(ctx.tenantId, ctx.settings?.plan, "customers");
if (!limitCheck.allowed) {
return { ok: false, error: limitErrorMessage("customers", limitCheck.limit, ctx.role) };
}
const { tablesDB } = createAdminClient(); const { tablesDB } = createAdminClient();
const data = parsed.data; const data = parsed.data;
+2 -2
View File
@@ -36,7 +36,7 @@ export async function uploadLogoAction(
let ctx; let ctx;
try { try {
ctx = await requireTenant(); ctx = await requireTenant();
requireRole(ctx, ["owner", "admin"]); requireRole(ctx, ["owner"]);
} catch { } catch {
return { ok: false, error: "Logo yüklemek için yönetici yetkisi gerekli." }; return { ok: false, error: "Logo yüklemek için yönetici yetkisi gerekli." };
} }
@@ -121,7 +121,7 @@ export async function removeLogoAction(): Promise<LogoActionState> {
let ctx; let ctx;
try { try {
ctx = await requireTenant(); ctx = await requireTenant();
requireRole(ctx, ["owner", "admin"]); requireRole(ctx, ["owner"]);
} catch { } catch {
return { ok: false, error: "Logo silmek için yönetici yetkisi gerekli." }; return { ok: false, error: "Logo silmek için yönetici yetkisi gerekli." };
} }
+6
View File
@@ -8,6 +8,7 @@ import { presentationSchema } from "@/lib/validation/presentations";
import { DATABASE_ID, TABLES } from "./schema"; import { DATABASE_ID, TABLES } from "./schema";
import { createAdminClient } from "./server"; import { createAdminClient } from "./server";
import { requireTenant } from "@/lib/appwrite/tenant-guard"; import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { checkLimit, limitErrorMessage } from "@/lib/plans";
type ActionState = { ok: boolean; error?: string; fieldErrors?: Record<string, string[]>; id?: string }; type ActionState = { ok: boolean; error?: string; fieldErrors?: Record<string, string[]>; id?: string };
@@ -26,6 +27,11 @@ export async function createPresentationAction(
return { ok: false, fieldErrors: parsed.error.flatten().fieldErrors }; return { ok: false, fieldErrors: parsed.error.flatten().fieldErrors };
} }
const limitCheck = await checkLimit(ctx.tenantId, ctx.settings?.plan, "presentations");
if (!limitCheck.allowed) {
return { ok: false, error: limitErrorMessage("presentations", limitCheck.limit, ctx.role) };
}
const { tablesDB } = createAdminClient(); const { tablesDB } = createAdminClient();
const data = parsed.data; const data = parsed.data;
const id = ID.unique(); const id = ID.unique();
+6
View File
@@ -8,6 +8,7 @@ import { DATABASE_ID, TABLES, type Property } from "./schema";
import { createAdminClient } from "./server"; import { createAdminClient } from "./server";
import { requireTenant } from "@/lib/appwrite/tenant-guard"; import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { matchPropertyToSearches } from "./matching"; import { matchPropertyToSearches } from "./matching";
import { checkLimit, limitErrorMessage } from "@/lib/plans";
type ActionState = { ok: boolean; error?: string; fieldErrors?: Record<string, string[]> }; type ActionState = { ok: boolean; error?: string; fieldErrors?: Record<string, string[]> };
@@ -22,6 +23,11 @@ export async function createPropertyAction(
return { ok: false, fieldErrors: parsed.error.flatten().fieldErrors }; return { ok: false, fieldErrors: parsed.error.flatten().fieldErrors };
} }
const limitCheck = await checkLimit(ctx.tenantId, ctx.settings?.plan, "properties");
if (!limitCheck.allowed) {
return { ok: false, error: limitErrorMessage("properties", limitCheck.limit, ctx.role) };
}
const { tablesDB } = createAdminClient(); const { tablesDB } = createAdminClient();
const id = ID.unique(); const id = ID.unique();
const data = parsed.data; const data = parsed.data;
+6
View File
@@ -10,6 +10,7 @@ import { createAdminClient, createSessionClient } from "./server";
import { requireRole, requireTenant } from "./tenant-guard"; import { requireRole, requireTenant } from "./tenant-guard";
import type { InviteState, MemberActionState } from "./team-types"; import type { InviteState, MemberActionState } from "./team-types";
import { ACTIVE_TENANT_COOKIE } from "./tenant-types"; import { ACTIVE_TENANT_COOKIE } from "./tenant-types";
import { checkLimit, limitErrorMessage } from "@/lib/plans";
const APP_URL = process.env.APP_URL ?? "http://localhost:3000"; const APP_URL = process.env.APP_URL ?? "http://localhost:3000";
const INVITE_TTL_DAYS = 7; const INVITE_TTL_DAYS = 7;
@@ -66,6 +67,11 @@ export async function inviteMemberAction(
return { ok: false, error: "Kendinizi davet edemezsiniz." }; return { ok: false, error: "Kendinizi davet edemezsiniz." };
} }
const memberLimit = await checkLimit(ctx.tenantId, ctx.settings?.plan, "teamMembers");
if (!memberLimit.allowed) {
return { ok: false, error: limitErrorMessage("teamMembers", memberLimit.limit, ctx.role) };
}
const admin = createAdminClient(); const admin = createAdminClient();
// 1. Kullanıcı zaten Appwrite'ta var mı? // 1. Kullanıcı zaten Appwrite'ta var mı?
+3
View File
@@ -15,6 +15,7 @@ export type TenantContext = {
tenantId: string; tenantId: string;
role: TenantRole; role: TenantRole;
settings: TenantSettings | null; settings: TenantSettings | null;
memberCount: number;
}; };
function pickHighestRole(roles: string[]): TenantRole | null { function pickHighestRole(roles: string[]): TenantRole | null {
@@ -124,6 +125,7 @@ export async function requireTenant(): Promise<TenantContext> {
if (!membership) throw new Error("NOT_A_MEMBER"); if (!membership) throw new Error("NOT_A_MEMBER");
const role = pickHighestRole(membership.roles) ?? "member"; const role = pickHighestRole(membership.roles) ?? "member";
const memberCount = memberships.memberships.filter((m) => m.confirm).length;
let settings: TenantSettings | null = null; let settings: TenantSettings | null = null;
try { try {
@@ -137,6 +139,7 @@ export async function requireTenant(): Promise<TenantContext> {
tenantId, tenantId,
role, role,
settings, settings,
memberCount,
}; };
} }
+1 -1
View File
@@ -42,7 +42,7 @@ export async function updateWorkspaceSettingsAction(
let ctx; let ctx;
try { try {
ctx = await requireTenant(); ctx = await requireTenant();
requireRole(ctx, ["owner", "admin"]); requireRole(ctx, ["owner"]);
} catch { } catch {
return { ok: false, error: "Düzenleme yetkiniz yok." }; return { ok: false, error: "Düzenleme yetkiniz yok." };
} }
+88
View File
@@ -0,0 +1,88 @@
import "server-only";
import { Query } from "node-appwrite";
import { createAdminClient } from "./appwrite/server";
import { DATABASE_ID, TABLES, type TenantPlan } from "./appwrite/schema";
export const PLAN_LIMITS = {
free: {
properties: 15,
customers: 25,
presentations: 5,
teamMembers: 1,
propertyImages: 5,
},
pro: {
properties: Infinity,
customers: Infinity,
presentations: Infinity,
teamMembers: Infinity,
propertyImages: Infinity,
},
} as const;
export type LimitKey = keyof typeof PLAN_LIMITS.free;
export type LimitResult = { allowed: boolean; current: number; limit: number };
export async function checkLimit(
tenantId: string,
plan: TenantPlan | undefined | null,
key: LimitKey,
): Promise<LimitResult> {
const planKey: TenantPlan = plan ?? "free";
const limit = PLAN_LIMITS[planKey][key];
if (limit === Infinity) return { allowed: true, current: 0, limit: Infinity };
const { tablesDB, teams } = createAdminClient();
let current = 0;
if (key === "properties") {
const r = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.properties,
queries: [Query.equal("tenantId", tenantId), Query.limit(1)],
});
current = r.total;
} else if (key === "customers") {
const r = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.customers,
queries: [Query.equal("tenantId", tenantId), Query.limit(1)],
});
current = r.total;
} else if (key === "presentations") {
const r = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.presentations,
queries: [Query.equal("tenantId", tenantId), Query.limit(1)],
});
current = r.total;
} else if (key === "teamMembers") {
const r = await teams.listMemberships(tenantId);
current = r.memberships.filter((m) => m.confirm).length;
}
return { allowed: current < limit, current, limit };
}
export function limitLabel(key: LimitKey): string {
const labels: Record<LimitKey, string> = {
properties: "ilan",
customers: "müşteri",
presentations: "sunum",
teamMembers: "ekip üyesi",
propertyImages: "fotoğraf",
};
return labels[key];
}
export function limitErrorMessage(key: LimitKey, limit: number, role: "owner" | "admin" | "member"): string {
if (role === "owner") {
return `Ücretsiz planda en fazla ${limit} ${limitLabel(key)} ekleyebilirsiniz. Pro'ya geçerek limitsiz kullanın.`;
}
return `Bu çalışma alanı ${limitLabel(key)} limitine ulaştı. Yöneticinizle iletişime geçin.`;
}