+ {isOwner && usageLimit && (
+
+ )}
{/* View toggle */}
{([
@@ -397,6 +404,8 @@ export function PropertiesClient({ initialProperties }: PropertiesClientProps) {
onOpenChange={setSheetOpen}
property={editing}
onSuccess={() => router.refresh()}
+ maxImages={maxImagesPerProperty}
+ isOwner={isOwner}
/>
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
>
),
diff --git a/src/components/properties/property-image-uploader.tsx b/src/components/properties/property-image-uploader.tsx
index 1ebfa77..3b7b976 100644
--- a/src/components/properties/property-image-uploader.tsx
+++ b/src/components/properties/property-image-uploader.tsx
@@ -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
(initialImageIds);
const [queue, setQueue] = useState([]);
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;
diff --git a/src/components/ui/usage-badge.tsx b/src/components/ui/usage-badge.tsx
new file mode 100644
index 0000000..8e78e77
--- /dev/null
+++ b/src/components/ui/usage-badge.tsx
@@ -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 (
+
+ {isFull && }
+ {current}/{limit} {label}
+
+ );
+}
+
+interface UpgradeBannerProps {
+ message: string;
+}
+
+export function UpgradeBanner({ message }: UpgradeBannerProps) {
+ return (
+
+ );
+}
diff --git a/src/lib/appwrite/active-context.ts b/src/lib/appwrite/active-context.ts
index 156a08d..14c16da 100644
--- a/src/lib/appwrite/active-context.ts
+++ b/src/lib/appwrite/active-context.ts
@@ -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 {
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 {
user: { id: user.$id, name: user.name, email: user.email },
tenantId,
settings,
+ role,
+ memberCount,
};
}
diff --git a/src/lib/appwrite/customer-actions.ts b/src/lib/appwrite/customer-actions.ts
index cc08c31..9745a48 100644
--- a/src/lib/appwrite/customer-actions.ts
+++ b/src/lib/appwrite/customer-actions.ts
@@ -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 };
@@ -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;
diff --git a/src/lib/appwrite/logo-actions.ts b/src/lib/appwrite/logo-actions.ts
index 5c96a7f..113cfcc 100644
--- a/src/lib/appwrite/logo-actions.ts
+++ b/src/lib/appwrite/logo-actions.ts
@@ -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 {
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." };
}
diff --git a/src/lib/appwrite/presentation-actions.ts b/src/lib/appwrite/presentation-actions.ts
index 3330038..196bfaa 100644
--- a/src/lib/appwrite/presentation-actions.ts
+++ b/src/lib/appwrite/presentation-actions.ts
@@ -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; 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();
diff --git a/src/lib/appwrite/property-actions.ts b/src/lib/appwrite/property-actions.ts
index b6daa94..e648603 100644
--- a/src/lib/appwrite/property-actions.ts
+++ b/src/lib/appwrite/property-actions.ts
@@ -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 };
@@ -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;
diff --git a/src/lib/appwrite/team-actions.ts b/src/lib/appwrite/team-actions.ts
index 7c75e68..a98d19c 100644
--- a/src/lib/appwrite/team-actions.ts
+++ b/src/lib/appwrite/team-actions.ts
@@ -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ı?
diff --git a/src/lib/appwrite/tenant-guard.ts b/src/lib/appwrite/tenant-guard.ts
index fc0a992..7f519fb 100644
--- a/src/lib/appwrite/tenant-guard.ts
+++ b/src/lib/appwrite/tenant-guard.ts
@@ -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 {
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 {
tenantId,
role,
settings,
+ memberCount,
};
}
diff --git a/src/lib/appwrite/workspace-actions.ts b/src/lib/appwrite/workspace-actions.ts
index af68f65..e0a02ab 100644
--- a/src/lib/appwrite/workspace-actions.ts
+++ b/src/lib/appwrite/workspace-actions.ts
@@ -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." };
}
diff --git a/src/lib/plans.ts b/src/lib/plans.ts
new file mode 100644
index 0000000..f872636
--- /dev/null
+++ b/src/lib/plans.ts
@@ -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 {
+ 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 = {
+ 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.`;
+}