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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user