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:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'ya Geç
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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." };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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ı?
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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." };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user