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 { listCustomers } from "@/lib/appwrite/customer-queries";
|
||||
import { CustomersClient } from "@/components/customers/customers-client";
|
||||
import { checkLimit, PLAN_LIMITS } from "@/lib/plans";
|
||||
|
||||
export default async function CustomersPage() {
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,18 +24,22 @@ export type ShellCompany = {
|
||||
logoUrl?: string | null;
|
||||
};
|
||||
|
||||
export type ShellRole = "owner" | "admin" | "member";
|
||||
|
||||
export function DashboardShell({
|
||||
user,
|
||||
company,
|
||||
children,
|
||||
initialPrefs,
|
||||
pendingMatchCount = 0,
|
||||
role = "member",
|
||||
}: {
|
||||
user: ShellUser;
|
||||
company: ShellCompany;
|
||||
children: React.ReactNode;
|
||||
initialPrefs: ThemePrefs;
|
||||
pendingMatchCount?: number;
|
||||
role?: ShellRole;
|
||||
}) {
|
||||
const [themeCustomizerOpen, setThemeCustomizerOpen] = React.useState(false);
|
||||
const { config } = useSidebarConfig();
|
||||
@@ -63,6 +67,7 @@ export function DashboardShell({
|
||||
collapsible={config.collapsible}
|
||||
side={config.side}
|
||||
pendingMatchCount={pendingMatchCount}
|
||||
role={role}
|
||||
/>
|
||||
<SidebarInset>
|
||||
<SiteHeader company={company} />
|
||||
@@ -84,6 +89,7 @@ export function DashboardShell({
|
||||
collapsible={config.collapsible}
|
||||
side={config.side}
|
||||
pendingMatchCount={pendingMatchCount}
|
||||
role={role}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
@@ -9,6 +10,7 @@ import { InvestorsClient } from "@/components/investors/investors-client";
|
||||
|
||||
export default async function InvestorsPage() {
|
||||
const ctx = await requireTenant();
|
||||
if (ctx.role === "member") redirect("/dashboard");
|
||||
const { tablesDB } = createAdminClient();
|
||||
|
||||
const result = await tablesDB.listRows({
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getLogoUrl } from "@/lib/appwrite/storage";
|
||||
import { createAdminClient, createSessionClient, getCurrentUser } from "@/lib/appwrite/server";
|
||||
import { DATABASE_ID, TABLES } from "@/lib/appwrite/schema";
|
||||
import type { ThemePrefs } from "@/lib/appwrite/theme-prefs-actions";
|
||||
import { PLAN_LIMITS } from "@/lib/plans";
|
||||
import { DashboardShell } from "./dashboard-shell";
|
||||
|
||||
export default async function DashboardLayout({
|
||||
@@ -19,6 +20,13 @@ export default async function DashboardLayout({
|
||||
const ctx = await getActiveContext();
|
||||
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;
|
||||
try {
|
||||
const { tablesDB } = createAdminClient();
|
||||
@@ -53,7 +61,7 @@ export default async function DashboardLayout({
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardShell user={user} company={company} initialPrefs={themePrefs} pendingMatchCount={pendingMatchCount}>
|
||||
<DashboardShell user={user} company={company} initialPrefs={themePrefs} pendingMatchCount={pendingMatchCount} role={ctx.role}>
|
||||
{children}
|
||||
</DashboardShell>
|
||||
);
|
||||
|
||||
@@ -8,12 +8,13 @@ import { listProperties } from "@/lib/appwrite/property-queries";
|
||||
import { DATABASE_ID, TABLES, type Presentation } from "@/lib/appwrite/schema";
|
||||
import { createAdminClient } from "@/lib/appwrite/server";
|
||||
import { PresentationsClient } from "@/components/presentations/presentations-client";
|
||||
import { checkLimit, PLAN_LIMITS } from "@/lib/plans";
|
||||
|
||||
export default async function PresentationsPage() {
|
||||
const ctx = await requireTenant();
|
||||
const { tablesDB } = createAdminClient();
|
||||
|
||||
const [customers, properties, presResult] = await Promise.all([
|
||||
const [customers, properties, presResult, limitResult] = await Promise.all([
|
||||
listCustomers(ctx.tenantId),
|
||||
listProperties(ctx.tenantId),
|
||||
tablesDB.listRows({
|
||||
@@ -25,12 +26,15 @@ export default async function PresentationsPage() {
|
||||
Query.limit(200),
|
||||
],
|
||||
}),
|
||||
checkLimit(ctx.tenantId, ctx.settings?.plan, "presentations"),
|
||||
]).catch((e) => {
|
||||
console.error("[PresentationsPage] data fetch failed:", e);
|
||||
throw e;
|
||||
});
|
||||
|
||||
const presentations = JSON.parse(JSON.stringify(presResult.rows)) as Presentation[];
|
||||
const plan = ctx.settings?.plan ?? "free";
|
||||
const presLimit = PLAN_LIMITS[plan].presentations;
|
||||
|
||||
return (
|
||||
<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}
|
||||
customers={customers}
|
||||
properties={properties}
|
||||
isOwner={ctx.role === "owner"}
|
||||
usageLimit={presLimit !== Infinity ? { current: limitResult.current, limit: presLimit } : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,14 +3,28 @@ export const dynamic = "force-dynamic";
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { listProperties } from "@/lib/appwrite/property-queries";
|
||||
import { PropertiesClient } from "@/components/properties/properties-client";
|
||||
import { checkLimit, PLAN_LIMITS } from "@/lib/plans";
|
||||
|
||||
export default async function PropertiesPage() {
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ export default async function BillingPage({
|
||||
redirect("/onboarding");
|
||||
}
|
||||
|
||||
if (ctx.role !== "owner") redirect("/dashboard");
|
||||
|
||||
const params = await searchParams;
|
||||
const upgraded = params.upgraded === "1";
|
||||
const downgraded = params.downgraded === "1";
|
||||
|
||||
@@ -23,6 +23,8 @@ export default async function MembersPage() {
|
||||
redirect("/onboarding");
|
||||
}
|
||||
|
||||
if (ctx.role === "member") redirect("/dashboard");
|
||||
|
||||
const canManage = ctx.role === "owner" || ctx.role === "admin";
|
||||
const isOwner = ctx.role === "owner";
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@ export default async function WorkspaceSettingsPage() {
|
||||
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";
|
||||
|
||||
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,
|
||||
} 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",
|
||||
items: [
|
||||
@@ -70,6 +87,7 @@ const navGroups = [
|
||||
title: "Yatırımcılar",
|
||||
url: "/investors",
|
||||
icon: Wallet,
|
||||
minRole: "admin" as ShellRole,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -100,6 +118,7 @@ const navGroups = [
|
||||
title: "Çalışma Alanı",
|
||||
url: "/settings/workspace",
|
||||
icon: GearSix,
|
||||
minRole: "admin" as ShellRole,
|
||||
items: [
|
||||
{ title: "Ofis Bilgileri", url: "/settings/workspace" },
|
||||
{ title: "Ekip Üyeleri", url: "/settings/members" },
|
||||
@@ -114,6 +133,7 @@ const navGroups = [
|
||||
title: "Plan & Faturalama",
|
||||
url: "/settings/billing",
|
||||
icon: CreditCard,
|
||||
minRole: "owner" as ShellRole,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -123,24 +143,29 @@ export function AppSidebar({
|
||||
user,
|
||||
company,
|
||||
pendingMatchCount = 0,
|
||||
role = "member",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Sidebar> & {
|
||||
user: ShellUser;
|
||||
company: ShellCompany;
|
||||
pendingMatchCount?: number;
|
||||
role?: ShellRole;
|
||||
}) {
|
||||
// Inject badge into the Eşleşmeler sub-item
|
||||
const groups = navGroups.map((group) => ({
|
||||
...group,
|
||||
items: group.items.map((item) => ({
|
||||
...item,
|
||||
items: item.items?.map((sub) =>
|
||||
sub.url === "/customers/matches" && pendingMatchCount > 0
|
||||
? { ...sub, badge: pendingMatchCount }
|
||||
: sub,
|
||||
),
|
||||
})),
|
||||
}));
|
||||
const groups = navGroups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items
|
||||
.filter((item) => hasAccess(item.minRole, role))
|
||||
.map((item) => ({
|
||||
...item,
|
||||
items: item.items?.map((sub) =>
|
||||
sub.url === "/customers/matches" && pendingMatchCount > 0
|
||||
? { ...sub, badge: pendingMatchCount }
|
||||
: sub,
|
||||
),
|
||||
})),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0);
|
||||
|
||||
return (
|
||||
<Sidebar {...props}>
|
||||
@@ -174,7 +199,11 @@ export function AppSidebar({
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{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>
|
||||
<SidebarFooter>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { deleteCustomerAction } from "@/lib/appwrite/customer-actions";
|
||||
import { CustomerFormSheet } from "./customer-form-sheet";
|
||||
import { CustomersPipeline } from "./customers-pipeline";
|
||||
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 {
|
||||
CUSTOMER_TYPE_LABELS,
|
||||
@@ -29,9 +30,11 @@ type ViewMode = "list" | "pipeline";
|
||||
|
||||
interface CustomersClientProps {
|
||||
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 [customers, setCustomers] = useState(initialCustomers);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
@@ -79,7 +82,10 @@ export function CustomersClient({ initialCustomers }: CustomersClientProps) {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<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 */}
|
||||
<div className="flex rounded-md border overflow-hidden">
|
||||
<button
|
||||
|
||||
@@ -15,18 +15,23 @@ import {
|
||||
import { deletePresentationAction } from "@/lib/appwrite/presentation-actions";
|
||||
import { PresentationFormSheet } from "./presentation-form-sheet";
|
||||
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||
import { UsageBadge } from "@/components/ui/usage-badge";
|
||||
import type { Customer, Presentation, Property } from "@/lib/appwrite/schema";
|
||||
|
||||
interface PresentationsClientProps {
|
||||
initialPresentations: Presentation[];
|
||||
customers: Customer[];
|
||||
properties: Property[];
|
||||
isOwner?: boolean;
|
||||
usageLimit?: { current: number; limit: number };
|
||||
}
|
||||
|
||||
export function PresentationsClient({
|
||||
initialPresentations,
|
||||
customers,
|
||||
properties,
|
||||
isOwner,
|
||||
usageLimit,
|
||||
}: PresentationsClientProps) {
|
||||
const router = useRouter();
|
||||
const [presentations, setPresentations] = useState(initialPresentations);
|
||||
@@ -86,12 +91,17 @@ export function PresentationsClient({
|
||||
|
||||
return (
|
||||
<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>
|
||||
<Button onClick={openCreate} size="sm" data-tour="presentations-add">
|
||||
<Plus className="mr-1.5 size-4" />
|
||||
Yeni Sunum
|
||||
</Button>
|
||||
<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">
|
||||
<Plus className="mr-1.5 size-4" />
|
||||
Yeni Sunum
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-tour="presentations-table" className="rounded-md border">
|
||||
|
||||
@@ -20,6 +20,7 @@ import { ImageLightbox } from "./image-lightbox";
|
||||
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||
import { PropertiesMapView } from "@/components/map/properties-map-view";
|
||||
import { getPropertyImagePreviewUrl, parseImageIds } from "@/lib/appwrite/storage-utils";
|
||||
import { UsageBadge } from "@/components/ui/usage-badge";
|
||||
import type { Property, PropertyStatus } from "@/lib/appwrite/schema";
|
||||
import {
|
||||
PROPERTY_STATUS_LABELS,
|
||||
@@ -29,11 +30,14 @@ import {
|
||||
|
||||
interface PropertiesClientProps {
|
||||
initialProperties: Property[];
|
||||
isOwner?: boolean;
|
||||
usageLimit?: { current: number; limit: number };
|
||||
maxImagesPerProperty?: number;
|
||||
}
|
||||
|
||||
type ViewMode = "list" | "gallery" | "map";
|
||||
|
||||
export function PropertiesClient({ initialProperties }: PropertiesClientProps) {
|
||||
export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxImagesPerProperty }: PropertiesClientProps) {
|
||||
const router = useRouter();
|
||||
const [properties, setProperties] = useState(initialProperties);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
@@ -125,7 +129,10 @@ export function PropertiesClient({ initialProperties }: PropertiesClientProps) {
|
||||
</p>
|
||||
)}
|
||||
</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 */}
|
||||
<div className="flex rounded-md border overflow-hidden">
|
||||
{([
|
||||
@@ -397,6 +404,8 @@ export function PropertiesClient({ initialProperties }: PropertiesClientProps) {
|
||||
onOpenChange={setSheetOpen}
|
||||
property={editing}
|
||||
onSuccess={() => router.refresh()}
|
||||
maxImages={maxImagesPerProperty}
|
||||
isOwner={isOwner}
|
||||
/>
|
||||
<DeleteConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
|
||||
@@ -34,9 +34,11 @@ interface PropertyFormSheetProps {
|
||||
onOpenChange: (v: boolean) => void;
|
||||
property?: Property | null;
|
||||
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 action = property ? updatePropertyAction.bind(null, property.$id) : createPropertyAction;
|
||||
|
||||
@@ -207,7 +209,7 @@ export function PropertyFormSheet({ open, onOpenChange, property, onSuccess }: P
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Fotoğraflar</Label>
|
||||
<PropertyImageUploader name="imageIds" initialImageIds={parseImageIds(property?.imageIds)} />
|
||||
<PropertyImageUploader name="imageIds" initialImageIds={parseImageIds(property?.imageIds)} maxImages={maxImages} isOwner={isOwner} />
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
|
||||
@@ -15,9 +15,11 @@ interface PropertyImageUploaderProps {
|
||||
name: string;
|
||||
initialImageIds?: string[];
|
||||
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 [queue, setQueue] = useState<UploadingFile[]>([]);
|
||||
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.`);
|
||||
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();
|
||||
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.send(fd);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [imageIds, maxImages]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleFiles = useCallback((files: FileList | null) => {
|
||||
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 { 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 = {
|
||||
user: { id: string; name: string; email: string };
|
||||
tenantId: string;
|
||||
settings: TenantSettings | null;
|
||||
role: "owner" | "admin" | "member";
|
||||
memberCount: number;
|
||||
};
|
||||
|
||||
async function setActiveTenantCookie(tenantId: string) {
|
||||
@@ -72,13 +80,22 @@ export async function getActiveContext(): Promise<ActiveContext | null> {
|
||||
if (!tenantId) return null;
|
||||
|
||||
let settings: TenantSettings | null = null;
|
||||
let role: "owner" | "admin" | "member" = "member";
|
||||
let memberCount = 1;
|
||||
|
||||
try {
|
||||
const result = await tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.tenantSettings,
|
||||
queries: [Query.equal("tenantId", tenantId), Query.limit(1)],
|
||||
});
|
||||
settings = (result.rows[0] as unknown as TenantSettings) ?? null;
|
||||
const [settingsResult, membershipsResult] = await Promise.all([
|
||||
tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.tenantSettings,
|
||||
queries: [Query.equal("tenantId", tenantId), Query.limit(1)],
|
||||
}),
|
||||
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 {
|
||||
settings = null;
|
||||
}
|
||||
@@ -87,5 +104,7 @@ export async function getActiveContext(): Promise<ActiveContext | null> {
|
||||
user: { id: user.$id, name: user.name, email: user.email },
|
||||
tenantId,
|
||||
settings,
|
||||
role,
|
||||
memberCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { customerSchema } from "@/lib/validation/customers";
|
||||
import { DATABASE_ID, TABLES, type CustomerStage } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { checkLimit, limitErrorMessage } from "@/lib/plans";
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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 data = parsed.data;
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ export async function uploadLogoAction(
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner", "admin"]);
|
||||
requireRole(ctx, ["owner"]);
|
||||
} catch {
|
||||
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;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner", "admin"]);
|
||||
requireRole(ctx, ["owner"]);
|
||||
} catch {
|
||||
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 { createAdminClient } from "./server";
|
||||
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 };
|
||||
|
||||
@@ -26,6 +27,11 @@ export async function createPresentationAction(
|
||||
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 data = parsed.data;
|
||||
const id = ID.unique();
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DATABASE_ID, TABLES, type Property } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { matchPropertyToSearches } from "./matching";
|
||||
import { checkLimit, limitErrorMessage } from "@/lib/plans";
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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 id = ID.unique();
|
||||
const data = parsed.data;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createAdminClient, createSessionClient } from "./server";
|
||||
import { requireRole, requireTenant } from "./tenant-guard";
|
||||
import type { InviteState, MemberActionState } from "./team-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 INVITE_TTL_DAYS = 7;
|
||||
@@ -66,6 +67,11 @@ export async function inviteMemberAction(
|
||||
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();
|
||||
|
||||
// 1. Kullanıcı zaten Appwrite'ta var mı?
|
||||
|
||||
@@ -15,6 +15,7 @@ export type TenantContext = {
|
||||
tenantId: string;
|
||||
role: TenantRole;
|
||||
settings: TenantSettings | null;
|
||||
memberCount: number;
|
||||
};
|
||||
|
||||
function pickHighestRole(roles: string[]): TenantRole | null {
|
||||
@@ -124,6 +125,7 @@ export async function requireTenant(): Promise<TenantContext> {
|
||||
if (!membership) throw new Error("NOT_A_MEMBER");
|
||||
|
||||
const role = pickHighestRole(membership.roles) ?? "member";
|
||||
const memberCount = memberships.memberships.filter((m) => m.confirm).length;
|
||||
|
||||
let settings: TenantSettings | null = null;
|
||||
try {
|
||||
@@ -137,6 +139,7 @@ export async function requireTenant(): Promise<TenantContext> {
|
||||
tenantId,
|
||||
role,
|
||||
settings,
|
||||
memberCount,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export async function updateWorkspaceSettingsAction(
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await requireTenant();
|
||||
requireRole(ctx, ["owner", "admin"]);
|
||||
requireRole(ctx, ["owner"]);
|
||||
} catch {
|
||||
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