feat: allow free plan users to set up rank tracking (#116)
This commit is contained in:
parent
aeafa5649d
commit
86d3714846
@ -7,12 +7,14 @@ export function ActionsMenu({
|
||||
onCopyKeywords,
|
||||
isRunning,
|
||||
hasData,
|
||||
checkDisabled,
|
||||
}: {
|
||||
onCheckNow: () => void;
|
||||
onExport: () => void;
|
||||
onCopyKeywords: () => void;
|
||||
isRunning: boolean;
|
||||
hasData: boolean;
|
||||
checkDisabled?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
@ -27,6 +29,7 @@ export function ActionsMenu({
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
||||
<div className="absolute right-0 top-full mt-1 z-50 rounded-lg border border-base-300 bg-base-100 shadow-lg py-1 min-w-[160px]">
|
||||
{!checkDisabled && (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-base-200"
|
||||
onClick={() => {
|
||||
@ -38,6 +41,7 @@ export function ActionsMenu({
|
||||
<Play className="size-3.5" />
|
||||
{isRunning ? "Running..." : "Check Now"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-base-200"
|
||||
onClick={() => {
|
||||
|
||||
@ -41,12 +41,12 @@ export function RankTrackingConfigModal({
|
||||
const [step, setStep] = useState<"config" | "keywords">("config");
|
||||
const [domain, setDomain] = useState(existingConfig?.domain ?? "");
|
||||
const [devices, setDevices] = useState<"both" | "desktop" | "mobile">(
|
||||
existingConfig?.devices ?? "both",
|
||||
existingConfig?.devices ?? "mobile",
|
||||
);
|
||||
const [locationCode, setLocationCode] = useState(
|
||||
existingConfig?.locationCode ?? DEFAULT_LOCATION_CODE,
|
||||
);
|
||||
const [serpDepth, setSerpDepth] = useState(existingConfig?.serpDepth ?? 20);
|
||||
const [serpDepth, setSerpDepth] = useState(existingConfig?.serpDepth ?? 40);
|
||||
const [schedule, setSchedule] = useState<"daily" | "weekly" | "manual">(
|
||||
existingConfig?.scheduleInterval ?? "weekly",
|
||||
);
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AutumnProvider, useCustomer } from "autumn-js/react";
|
||||
import {
|
||||
getLatestRankResults,
|
||||
estimateRankCheckCost,
|
||||
@ -15,6 +17,9 @@ import {
|
||||
SlidersHorizontal,
|
||||
Smartphone,
|
||||
} from "lucide-react";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
|
||||
import { SUBSCRIBE_ROUTE } from "@/shared/billing";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import { RankTrackingTable } from "./RankTrackingTable";
|
||||
import { exportRankTrackingCsv } from "./RankTrackingTableParts";
|
||||
@ -48,7 +53,41 @@ function isComparePeriod(v: string): v is ComparePeriod {
|
||||
return COMPARE_PERIODS.has(v);
|
||||
}
|
||||
|
||||
export function RankTrackingDomainDetail({
|
||||
export function RankTrackingDomainDetail(props: {
|
||||
config: RankTrackingConfig;
|
||||
projectId: string;
|
||||
onBack: () => void;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
return (
|
||||
<AutumnProvider>
|
||||
<RankTrackingDomainDetailInner {...props} />
|
||||
</AutumnProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function FreePlanAlert({ visible }: { visible: boolean }) {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className="alert alert-warning text-sm py-2">
|
||||
<AlertTriangle className="size-4" />
|
||||
<span>
|
||||
We only start to track keyword positions once you{" "}
|
||||
<Link
|
||||
to={SUBSCRIBE_ROUTE}
|
||||
search={{ upgrade: true }}
|
||||
className="link font-medium"
|
||||
>
|
||||
upgrade to the paid plan
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RankTrackingDomainDetailInner({
|
||||
config,
|
||||
projectId,
|
||||
onBack,
|
||||
@ -59,6 +98,14 @@ export function RankTrackingDomainDetail({
|
||||
onBack: () => void;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const { data: session } = useSession();
|
||||
const customerQuery = useCustomer({
|
||||
queryOptions: { enabled: Boolean(session?.user?.id) },
|
||||
});
|
||||
const isFreePlan =
|
||||
!!customerQuery.data &&
|
||||
getCustomerPlanStatus(customerQuery.data) === "free";
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const [showAddKeywords, setShowAddKeywords] = useState(false);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
@ -180,6 +227,8 @@ export function RankTrackingDomainDetail({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FreePlanAlert visible={isFreePlan} />
|
||||
|
||||
{/* Results card */}
|
||||
<div className="flex-1 flex flex-col min-w-0 border border-base-300 rounded-xl bg-base-100 overflow-hidden">
|
||||
{/* Domain header */}
|
||||
@ -322,6 +371,7 @@ export function RankTrackingDomainDetail({
|
||||
}}
|
||||
isRunning={isBusy}
|
||||
hasData={filtered.length > 0}
|
||||
checkDisabled={isFreePlan}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ import {
|
||||
} from "@tanstack/react-start/server";
|
||||
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
|
||||
import { beginRankCheckRun } from "@/server/features/rank-tracking/services/rankCheckRunGuards";
|
||||
import { customerHasManagedServiceAccess } from "@/server/billing/subscription";
|
||||
import { customerHasPaidPlan } from "@/server/billing/subscription";
|
||||
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
||||
import { computeNextCheckAt } from "@/shared/rank-tracking";
|
||||
|
||||
@ -29,11 +29,8 @@ export default {
|
||||
|
||||
for (const config of dueConfigs) {
|
||||
try {
|
||||
// Skip configs whose org no longer has paid access
|
||||
if (
|
||||
isHosted &&
|
||||
!(await customerHasManagedServiceAccess(config.organizationId))
|
||||
) {
|
||||
// Skip configs whose org doesn't have a paid plan
|
||||
if (isHosted && !(await customerHasPaidPlan(config.organizationId))) {
|
||||
console.log(
|
||||
`[cron] Skipping config ${config.id} (${config.domain}) — org ${config.organizationId} no longer has access`,
|
||||
);
|
||||
|
||||
@ -1,13 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID } from "@/shared/billing";
|
||||
import { AUTUMN_PAID_PLAN_FEATURE_ID } from "@/shared/billing";
|
||||
|
||||
const { checkMock, getOrCreateMock, isHostedServerAuthModeMock } = vi.hoisted(
|
||||
() => ({
|
||||
const { checkMock, getOrCreateMock } = vi.hoisted(() => ({
|
||||
checkMock: vi.fn(),
|
||||
getOrCreateMock: vi.fn(),
|
||||
isHostedServerAuthModeMock: vi.fn(),
|
||||
}),
|
||||
);
|
||||
}));
|
||||
|
||||
vi.mock("@/server/billing/autumn", () => ({
|
||||
autumn: {
|
||||
@ -19,13 +16,12 @@ vi.mock("@/server/billing/autumn", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/server/lib/runtime-env", () => ({
|
||||
isHostedServerAuthMode: isHostedServerAuthModeMock,
|
||||
isHostedServerAuthMode: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
customerHasManagedServiceAccess,
|
||||
customerHasPaidPlan,
|
||||
getOrCreateOrganizationCustomer,
|
||||
requireManagedServiceAccess,
|
||||
} from "./subscription";
|
||||
|
||||
describe("subscription billing", () => {
|
||||
@ -33,46 +29,21 @@ describe("subscription billing", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("checks the managed service access entitlement", async () => {
|
||||
it("checks the paid plan entitlement", async () => {
|
||||
checkMock.mockResolvedValue({ allowed: true });
|
||||
|
||||
await expect(customerHasManagedServiceAccess("org_123")).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
await expect(customerHasPaidPlan("org_123")).resolves.toBe(true);
|
||||
|
||||
expect(checkMock).toHaveBeenCalledWith({
|
||||
customerId: "org_123",
|
||||
featureId: AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID,
|
||||
featureId: AUTUMN_PAID_PLAN_FEATURE_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips the managed service check outside hosted mode", async () => {
|
||||
isHostedServerAuthModeMock.mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
requireManagedServiceAccess({
|
||||
organizationId: "org_123",
|
||||
userId: "user_123",
|
||||
userEmail: "alice@example.com",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(getOrCreateMock).not.toHaveBeenCalled();
|
||||
expect(checkMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws payment required when the org lacks managed service access", async () => {
|
||||
isHostedServerAuthModeMock.mockResolvedValue(true);
|
||||
getOrCreateMock.mockResolvedValue({ id: "org_123" });
|
||||
it("returns false when org lacks paid plan", async () => {
|
||||
checkMock.mockResolvedValue({ allowed: false });
|
||||
|
||||
await expect(
|
||||
requireManagedServiceAccess({
|
||||
organizationId: "org_123",
|
||||
userId: "user_123",
|
||||
userEmail: "alice@example.com",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "PAYMENT_REQUIRED" });
|
||||
await expect(customerHasPaidPlan("org_123")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("looks up the billing customer by organization id", async () => {
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import type { EnsuredUserContext } from "@/middleware/ensure-user/types";
|
||||
import { AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID } from "@/shared/billing";
|
||||
import { AUTUMN_PAID_PLAN_FEATURE_ID } from "@/shared/billing";
|
||||
import { autumn } from "@/server/billing/autumn";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
||||
|
||||
export type BillingCustomerContext = Pick<
|
||||
EnsuredUserContext,
|
||||
@ -29,24 +28,11 @@ export async function getOrCreateOrganizationCustomer(
|
||||
};
|
||||
}
|
||||
|
||||
export async function customerHasManagedServiceAccess(customerId: string) {
|
||||
export async function customerHasPaidPlan(customerId: string) {
|
||||
const result = await autumn.check({
|
||||
customerId,
|
||||
featureId: AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID,
|
||||
featureId: AUTUMN_PAID_PLAN_FEATURE_ID,
|
||||
});
|
||||
|
||||
return result.allowed;
|
||||
}
|
||||
|
||||
export async function requireManagedServiceAccess(
|
||||
context: BillingCustomerContext,
|
||||
) {
|
||||
if (!(await isHostedServerAuthMode())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const customer = await getOrCreateOrganizationCustomer(context);
|
||||
if (!(await customerHasManagedServiceAccess(customer.id))) {
|
||||
throw new AppError("PAYMENT_REQUIRED");
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,6 @@ import { AppError } from "@/server/lib/errors";
|
||||
import { errorHandlingMiddleware } from "@/middleware/errorHandling";
|
||||
import type { EnsuredUserContext } from "@/middleware/ensure-user/types";
|
||||
import { ensureUserMiddleware } from "@/middleware/ensureUser";
|
||||
import { requireManagedServiceAccess } from "@/server/billing/subscription";
|
||||
|
||||
const ensuredUserContextSchema: z.ZodType<EnsuredUserContext> = z.object({
|
||||
userId: z.string(),
|
||||
@ -32,7 +31,6 @@ export const globalServerFunctionMiddleware = [
|
||||
export const requireAuthenticatedContext = [
|
||||
createMiddleware({ type: "function" }).server(async ({ next, context }) => {
|
||||
const authenticatedContext = getAuthenticatedContext(context);
|
||||
await requireManagedServiceAccess(authenticatedContext);
|
||||
|
||||
return next({
|
||||
context: authenticatedContext,
|
||||
@ -44,8 +42,6 @@ export const requireProjectContext = [
|
||||
createMiddleware({ type: "function" }).server(async ({ next, context }) => {
|
||||
const authenticatedContext = getAuthenticatedContext(context);
|
||||
|
||||
await requireManagedServiceAccess(authenticatedContext);
|
||||
|
||||
if (!authenticatedContext.project) {
|
||||
throw new AppError(
|
||||
"INTERNAL_ERROR",
|
||||
|
||||
@ -3,7 +3,9 @@ import { waitUntil } from "cloudflare:workers";
|
||||
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
|
||||
import { RankTrackingService } from "@/server/features/rank-tracking/services/RankTrackingService";
|
||||
import { getLatestResults } from "@/server/features/rank-tracking/services/rankTrackingResults";
|
||||
import { asAppError } from "@/server/lib/errors";
|
||||
import { AppError, asAppError } from "@/server/lib/errors";
|
||||
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
||||
import { customerHasPaidPlan } from "@/server/billing/subscription";
|
||||
import { captureServerEvent } from "@/server/lib/posthog";
|
||||
import { requireProjectContext } from "@/serverFunctions/middleware";
|
||||
import {
|
||||
@ -83,6 +85,14 @@ export const triggerRankCheck = createServerFn({ method: "POST" })
|
||||
.middleware(requireProjectContext)
|
||||
.inputValidator((data: unknown) => triggerCheckSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
const isHosted = await isHostedServerAuthMode();
|
||||
if (isHosted && !(await customerHasPaidPlan(context.organizationId))) {
|
||||
throw new AppError(
|
||||
"PAYMENT_REQUIRED",
|
||||
"Upgrade to the paid plan to run rank checks",
|
||||
);
|
||||
}
|
||||
|
||||
const result = await RankTrackingService.triggerCheck({
|
||||
configId: data.configId,
|
||||
projectId: context.projectId,
|
||||
@ -145,6 +155,11 @@ export const addTrackingKeywords = createServerFn({ method: "POST" })
|
||||
|
||||
let checkTriggered = false;
|
||||
if (result.addedIds.length > 0) {
|
||||
const isHosted = await isHostedServerAuthMode();
|
||||
const hasPaidPlan =
|
||||
!isHosted || (await customerHasPaidPlan(context.organizationId));
|
||||
|
||||
if (hasPaidPlan) {
|
||||
try {
|
||||
const triggerResult = await RankTrackingService.triggerCheck({
|
||||
configId: data.configId,
|
||||
@ -173,6 +188,7 @@ export const addTrackingKeywords = createServerFn({ method: "POST" })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ...result, checkTriggered };
|
||||
});
|
||||
|
||||
@ -3,8 +3,7 @@ export const SUBSCRIBE_ROUTE = "/subscribe";
|
||||
|
||||
export const AUTUMN_PAID_PLAN_ID = "base-plan";
|
||||
export const AUTUMN_SEO_DATA_TOP_UP_PLAN_ID = "credit-top-up";
|
||||
export const AUTUMN_MANAGED_SERVICE_ACCESS_FEATURE_ID =
|
||||
"managed_service_access";
|
||||
export const AUTUMN_PAID_PLAN_FEATURE_ID = "paid_plan";
|
||||
export const AUTUMN_SEO_DATA_BALANCE_FEATURE_ID = "usage_credits";
|
||||
export const AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID = "topup_credits";
|
||||
export const AUTUMN_SEO_DATA_CREDITS_PER_USD = 1000;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user