Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/web/app/(app)/admin/products/product-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -510,7 +510,9 @@ export function ProductForm({
<div>
<span className="text-sm font-medium">Hide from unlimited members</span>
<p className="text-xs text-muted-foreground">
Members with unlimited credits won&apos;t see this product.
For hour bundles, hides from members with unlimited credits
for the bundle&apos;s resource type. Other products hide from
members with any unlimited credits.
</p>
</div>
</label>
Expand Down
42 changes: 28 additions & 14 deletions apps/web/app/(app)/store/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,21 +95,30 @@ export async function purchasePass(
.eq("space_id", spaceId)
.maybeSingle();

let isUnlimited = false;
let unlimitedResourceTypeIds: string[] = [];
if (member?.plan_id) {
const { data: configs } = await admin
.from("plan_credit_config")
.select("is_unlimited")
.select("resource_type_id, is_unlimited")
.eq("plan_id", member.plan_id);
isUnlimited = configs?.some((c) => c.is_unlimited) ?? false;
unlimitedResourceTypeIds =
configs?.filter((c) => c.is_unlimited).map((c) => c.resource_type_id) ?? [];
}

const productResourceTypeId = (
product.credit_grant_config as { resource_type_id?: string } | null
)?.resource_type_id;

if (
!isProductVisible(product.visibility_rules, {
isMember: member?.status === "active",
planId: member?.plan_id ?? null,
isUnlimited,
})
!isProductVisible(
product.visibility_rules,
{
isMember: member?.status === "active",
planId: member?.plan_id ?? null,
unlimitedResourceTypeIds,
},
productResourceTypeId,
)
) {
return { success: false, error: "This product is not available for your membership" };
}
Expand Down Expand Up @@ -265,23 +274,28 @@ export async function purchaseProduct(
.eq("space_id", spaceId)
.maybeSingle();

// Check if member has unlimited credits (for visibility filtering)
let isUnlimited = false;
// Check which resource types member has unlimited credits for
let unlimitedResourceTypeIds: string[] = [];
if (member?.plan_id) {
const { data: configs } = await admin
.from("plan_credit_config")
.select("is_unlimited")
.select("resource_type_id, is_unlimited")
.eq("plan_id", member.plan_id);
isUnlimited = configs?.some((c) => c.is_unlimited) ?? false;
unlimitedResourceTypeIds =
configs?.filter((c) => c.is_unlimited).map((c) => c.resource_type_id) ?? [];
}

const productResourceTypeId = (
product.credit_grant_config as { resource_type_id?: string } | null
)?.resource_type_id;

const memberContext = {
isMember: member?.status === "active",
planId: member?.plan_id ?? null,
isUnlimited,
unlimitedResourceTypeIds,
};

if (!isProductVisible(product.visibility_rules, memberContext)) {
if (!isProductVisible(product.visibility_rules, memberContext, productResourceTypeId)) {
return { success: false, error: "This product is not available for your membership" };
}

Expand Down
17 changes: 12 additions & 5 deletions apps/web/app/(app)/store/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ export default async function StorePage() {
[productsResult, memberResult, spaceResult] = await Promise.all([
supabase
.from("products")
.select("id, name, slug, description, price_cents, currency, category, purchase_flow, sort_order, visibility_rules")
.select("id, name, slug, description, price_cents, currency, category, purchase_flow, sort_order, visibility_rules, credit_grant_config")
.eq("active", true)
.neq("category", "subscription")
.order("sort_order", { ascending: true }),
supabase
.from("members")
.select("id, plan_id, status, plan:plans(id, plan_credit_config(is_unlimited))")
.select("id, plan_id, status, plan:plans(id, plan_credit_config(resource_type_id, is_unlimited))")
.eq("user_id", user.id)
.maybeSingle(),
supabase
Expand All @@ -49,19 +49,26 @@ export default async function StorePage() {
const planCreditConfigs = (
member?.plan as unknown as {
id: string;
plan_credit_config: Array<{ is_unlimited: boolean }>;
plan_credit_config: Array<{ resource_type_id: string; is_unlimited: boolean }>;
} | null
)?.plan_credit_config;

const memberContext = {
isMember: member?.status === "active",
planId: member?.plan_id ?? null,
isUnlimited: planCreditConfigs?.some((c) => c.is_unlimited) ?? false,
unlimitedResourceTypeIds:
planCreditConfigs
?.filter((c) => c.is_unlimited)
.map((c) => c.resource_type_id) ?? [],
};

// Filter products by visibility and enabled features
const visibleProducts = products.filter((p) => {
if (!isProductVisible(p.visibility_rules, memberContext)) return false;
const productResourceTypeId = (
p.credit_grant_config as { resource_type_id?: string } | null
)?.resource_type_id;
if (!isProductVisible(p.visibility_rules, memberContext, productResourceTypeId))
return false;
if (p.category === "pass" && features.passes === false) return false;
if (p.category === "hour_bundle" && features.credits === false) return false;
return true;
Expand Down
94 changes: 84 additions & 10 deletions apps/web/lib/products/visibility.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,26 @@
import { describe, expect, it } from "vitest";
import { isProductVisible } from "./visibility";

const member = { isMember: true, planId: "plan_a", isUnlimited: false };
const nonMember = { isMember: false, planId: null, isUnlimited: false };
const unlimitedMember = { isMember: true, planId: "plan_u", isUnlimited: true };
const DESK_RT = "rt_desk";
const MEETING_RT = "rt_meeting";

const member = { isMember: true, planId: "plan_a", unlimitedResourceTypeIds: [] as string[] };
const nonMember = { isMember: false, planId: null, unlimitedResourceTypeIds: [] as string[] };
const unlimitedAllMember = {
isMember: true,
planId: "plan_u",
unlimitedResourceTypeIds: [DESK_RT, MEETING_RT],
};
const unlimitedDeskMember = {
isMember: true,
planId: "plan_d",
unlimitedResourceTypeIds: [DESK_RT],
};
const unlimitedMeetingMember = {
isMember: true,
planId: "plan_m",
unlimitedResourceTypeIds: [MEETING_RT],
};

describe("isProductVisible", () => {
// ── No rules (permissive default) ──────────────────────────────────
Expand Down Expand Up @@ -81,24 +98,71 @@ describe("isProductVisible", () => {
expect(isProductVisible({ require_plan_ids: ["plan"] }, member)).toBe(false);
});

// ── exclude_unlimited ─────────────────────────────────────────────
// ── exclude_unlimited (no product resource type — blanket behavior)

it("hides product when exclude_unlimited and member is unlimited", () => {
expect(isProductVisible({ exclude_unlimited: true }, unlimitedMember)).toBe(false);
it("hides product when exclude_unlimited and member has any unlimited", () => {
expect(isProductVisible({ exclude_unlimited: true }, unlimitedAllMember)).toBe(false);
});

it("shows product when exclude_unlimited and member is not unlimited", () => {
it("shows product when exclude_unlimited and member has no unlimited", () => {
expect(isProductVisible({ exclude_unlimited: true }, member)).toBe(true);
});

it("shows product when exclude_unlimited is false for unlimited member", () => {
expect(isProductVisible({ exclude_unlimited: false }, unlimitedMember)).toBe(true);
expect(isProductVisible({ exclude_unlimited: false }, unlimitedAllMember)).toBe(true);
});

it("shows product for non-member when exclude_unlimited is true", () => {
expect(isProductVisible({ exclude_unlimited: true }, nonMember)).toBe(true);
});

// ── exclude_unlimited (with product resource type — per-type behavior) ─

it("hides meeting room bundle for member with unlimited meeting rooms", () => {
expect(
isProductVisible({ exclude_unlimited: true }, unlimitedMeetingMember, MEETING_RT),
).toBe(false);
});

it("shows meeting room bundle for member with unlimited desks only", () => {
expect(
isProductVisible({ exclude_unlimited: true }, unlimitedDeskMember, MEETING_RT),
).toBe(true);
});

it("hides desk bundle for member with unlimited desks", () => {
expect(
isProductVisible({ exclude_unlimited: true }, unlimitedDeskMember, DESK_RT),
).toBe(false);
});

it("shows desk bundle for member with unlimited meeting rooms only", () => {
expect(
isProductVisible({ exclude_unlimited: true }, unlimitedMeetingMember, DESK_RT),
).toBe(true);
});

it("hides both bundles for member with all unlimited", () => {
expect(
isProductVisible({ exclude_unlimited: true }, unlimitedAllMember, DESK_RT),
).toBe(false);
expect(
isProductVisible({ exclude_unlimited: true }, unlimitedAllMember, MEETING_RT),
).toBe(false);
});

it("shows resource-typed product when exclude_unlimited is false", () => {
expect(
isProductVisible({ exclude_unlimited: false }, unlimitedDeskMember, DESK_RT),
).toBe(true);
});

it("shows resource-typed product for limited member", () => {
expect(
isProductVisible({ exclude_unlimited: true }, member, DESK_RT),
).toBe(true);
});

// ── Combined rules ─────────────────────────────────────────────────

it("applies all rules — membership + plan match → visible", () => {
Expand Down Expand Up @@ -128,11 +192,11 @@ describe("isProductVisible", () => {
).toBe(true);
});

it("applies require_membership + exclude_unlimited — unlimited member is hidden", () => {
it("applies require_membership + exclude_unlimited — unlimited member is hidden (no resource type)", () => {
expect(
isProductVisible(
{ require_membership: true, exclude_unlimited: true },
unlimitedMember,
unlimitedAllMember,
),
).toBe(false);
});
Expand All @@ -146,6 +210,16 @@ describe("isProductVisible", () => {
).toBe(true);
});

it("applies membership + exclude_unlimited with resource type — desk-unlimited member sees meeting bundle", () => {
expect(
isProductVisible(
{ require_membership: true, exclude_unlimited: true },
unlimitedDeskMember,
MEETING_RT,
),
).toBe(true);
});

it("handles conflicting rules (require_membership + require_no_membership both true)", () => {
// A member fails require_no_membership; a non-member fails require_membership
expect(
Expand Down
17 changes: 15 additions & 2 deletions apps/web/lib/products/visibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ interface VisibilityRules {
interface MemberContext {
isMember: boolean;
planId: string | null;
isUnlimited: boolean;
unlimitedResourceTypeIds: string[];
}

export function isProductVisible(
rules: unknown,
member: MemberContext,
productResourceTypeId?: string | null,
): boolean {
const r = rules as VisibilityRules | null | undefined;
if (!r || Object.keys(r).length === 0) return true;
Expand All @@ -27,7 +28,19 @@ export function isProductVisible(
!r.require_plan_ids.includes(member.planId ?? "")
)
return false;
if (r.exclude_unlimited && member.isUnlimited) return false;

if (r.exclude_unlimited) {
if (productResourceTypeId) {
// Product is tied to a resource type — only hide if member has
// unlimited credits for that specific resource type
if (member.unlimitedResourceTypeIds.includes(productResourceTypeId))
return false;
} else {
// Product has no resource type — hide if member has unlimited
// credits for any resource type (original blanket behavior)
if (member.unlimitedResourceTypeIds.length > 0) return false;
}
}

return true;
}
Loading