← back to Open Seo

src/client/features/billing/BillingFeatureBreakdown.tsx

186 lines

import { useQuery } from "@tanstack/react-query";
import {
  AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
  AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
  autumnSeoDataCreditsToUsd,
} from "@/shared/billing";
import {
  creditFeatureLabel,
  mapDataforseoPathToCreditFeature,
} from "@/shared/billing-credit-features";
import {
  getBillingUsageEvents,
  type BillingUsageEvent,
} from "@/serverFunctions/billing";

const BILLING_USAGE_FEATURE_IDS: string[] = [
  AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
  AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
];

const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;

type BillingUsageEventProperties = {
  creditFeature?: unknown;
  credit_feature?: unknown;
  path?: unknown;
  paths?: unknown;
};

type BillingFeatureBreakdownRow = {
  label: string;
  usd: number;
};

type BillingUsageRange = {
  start: number;
  end: number;
};

function getLast30DayUsageRange(): BillingUsageRange {
  const end = Date.now();
  return {
    start: end - THIRTY_DAYS_MS,
    end,
  };
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null;
}

function getPathSegmentsFromProperties(
  properties: BillingUsageEventProperties,
): string[] | null {
  const paths = properties.paths ?? properties.path;
  if (Array.isArray(paths)) {
    const stringPaths = paths.filter(
      (value): value is string => typeof value === "string",
    );
    if (
      stringPaths.length > 1 &&
      stringPaths.every((segment) => !segment.includes("/"))
    ) {
      return stringPaths;
    }

    const path = stringPaths[0];
    if (!path) return null;

    const parsedPath = parseJsonEncodedPath(path);
    return parsedPath ?? path.split("/").filter(Boolean);
  }

  if (typeof paths !== "string") return null;

  const parsedPath = parseJsonEncodedPath(paths);
  return parsedPath ?? paths.split("/").filter(Boolean);
}

function parseJsonEncodedPath(path: string): string[] | null {
  if (!path.startsWith("[")) return null;

  try {
    const parsed: unknown = JSON.parse(path);
    if (!Array.isArray(parsed)) return null;
    const stringPaths = parsed.filter(
      (value): value is string => typeof value === "string",
    );
    if (
      stringPaths.length > 1 &&
      stringPaths.every((segment) => !segment.includes("/"))
    ) {
      return stringPaths;
    }

    const firstPath = stringPaths[0];
    return firstPath ? firstPath.split("/").filter(Boolean) : null;
  } catch {
    return null;
  }
}

function getCreditFeatureFromUsageEvent(
  event: BillingUsageEvent,
): string | null {
  const properties = isRecord(event.properties) ? event.properties : {};
  const explicitFeature = properties.creditFeature ?? properties.credit_feature;
  if (typeof explicitFeature === "string" && explicitFeature.length > 0) {
    return explicitFeature;
  }

  const path = getPathSegmentsFromProperties(properties);
  return path ? mapDataforseoPathToCreditFeature(path) : null;
}

export function getBillingFeatureBreakdownRows(
  events: BillingUsageEvent[],
): BillingFeatureBreakdownRow[] {
  const creditsByLabel = new Map<string, number>();

  for (const event of events) {
    const feature = getCreditFeatureFromUsageEvent(event);
    const label = feature ? creditFeatureLabel(feature) : "Other";
    creditsByLabel.set(label, (creditsByLabel.get(label) ?? 0) + event.value);
  }

  return [...creditsByLabel.entries()]
    .map(([label, credits]) => ({
      label,
      usd: autumnSeoDataCreditsToUsd(credits),
    }))
    .filter((row) => row.usd > 0)
    .toSorted((a, b) => b.usd - a.usd);
}

export function BillingFeatureBreakdown() {
  const eventsQuery = useQuery({
    queryKey: ["billing", "usage-events", BILLING_USAGE_FEATURE_IDS, "30d"],
    queryFn: () => getBillingUsageEvents({ data: getLast30DayUsageRange() }),
    staleTime: 60_000,
  });

  const rows = getBillingFeatureBreakdownRows(eventsQuery.data ?? []);
  const total = rows.reduce((sum, row) => sum + row.usd, 0);

  return (
    <div className="rounded-lg border border-base-300 bg-base-100 p-4 space-y-3">
      <div className="flex items-baseline justify-between gap-4">
        <span className="font-semibold">Usage by feature</span>
        <span className="text-xs text-base-content/50">Last 30 days</span>
      </div>

      {eventsQuery.isLoading ? (
        <div className="space-y-3">
          {[0, 1, 2, 3].map((i) => (
            <div key={i} className="skeleton h-4 w-full" />
          ))}
        </div>
      ) : rows.length === 0 ? (
        <div className="text-sm text-base-content/40">
          No usage recorded yet
        </div>
      ) : (
        <ul className="space-y-2.5">
          {rows.map((row) => (
            <li key={row.label} className="space-y-1">
              <div className="flex items-baseline justify-between gap-4 text-sm">
                <span>{row.label}</span>
                <span className="tabular-nums text-base-content/70">
                  ${row.usd.toFixed(2)}
                </span>
              </div>
              <div className="h-1.5 w-full overflow-hidden rounded-full bg-base-200">
                <div
                  className="h-full rounded-full bg-[#7c3aed]"
                  style={{ width: `${(row.usd / total) * 100}%` }}
                />
              </div>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}