← back to Dear Bubbe Nextjs

lib/bubbe-pricing.ts

309 lines

/**
 * Bubbe Pricing + Feature Flags System
 *
 * SAFE: Read-only helper for plans and features.
 * Does NOT modify existing Bubbe chat logic.
 * You can version this as v2 without changing any current behavior.
 */

export type PlanId = "basic" | "plus" | "max" | "supermax";

export type FeatureKey =
  | "advice_guilt"
  | "yiddish_sayings"
  | "daily_reminders"
  | "voice_notes"
  | "eat_skinny_mode"
  | "cooking_tips"
  | "family_gossip"
  | "oy_vey_responses"
  | "storytelling"
  | "yiddish_dictionary_basic"
  | "yiddish_dictionary_full"
  | "holiday_mode"
  | "local_news"
  | "local_sports"
  | "local_weather"
  | "personalized_insights";

export const PLAN_FEATURES: Record<PlanId, FeatureKey[]> = {
  basic: [
    "advice_guilt",
    "yiddish_sayings",
    "oy_vey_responses",
    "storytelling",
    "yiddish_dictionary_basic"
  ],
  plus: [
    "advice_guilt",
    "yiddish_sayings",
    "oy_vey_responses",
    "storytelling",
    "yiddish_dictionary_basic",
    "daily_reminders",
    "eat_skinny_mode",
    "cooking_tips",
    "family_gossip"
  ],
  max: [
    "advice_guilt",
    "yiddish_sayings",
    "oy_vey_responses",
    "storytelling",
    "yiddish_dictionary_basic",
    "yiddish_dictionary_full",
    "daily_reminders",
    "eat_skinny_mode",
    "cooking_tips",
    "family_gossip",
    "voice_notes",
    "holiday_mode"
  ],
  supermax: [
    "advice_guilt",
    "yiddish_sayings",
    "oy_vey_responses",
    "storytelling",
    "yiddish_dictionary_basic",
    "yiddish_dictionary_full",
    "daily_reminders",
    "eat_skinny_mode",
    "cooking_tips",
    "family_gossip",
    "voice_notes",
    "holiday_mode",
    "local_news",
    "local_sports",
    "local_weather",
    "personalized_insights"
  ]
};

export const PLAN_PRICES: Record<PlanId, number> = {
  basic: 0,
  plus: 18,
  max: 36,
  supermax: 54
};

export const PLAN_NAMES: Record<PlanId, string> = {
  basic: "Basic – Free",
  plus: "Bubbe Plus",
  max: "Bubbe Max",
  supermax: "Super Max"
};

export const PLAN_DESCRIPTIONS: Record<PlanId, string> = {
  basic: "A taste of Bubbe's wisdom",
  plus: "The full Bubbe experience",
  max: "Bubbe on steroids – all the guilt, all the time",
  supermax: "Bubbe knows EVERYTHING about your neighborhood – oy gevalt!"
};

export const FEATURE_DESCRIPTIONS: Record<FeatureKey, string> = {
  advice_guilt: "Classic Bubbe advice & guilt trips",
  yiddish_sayings: "Basic Yiddish sayings",
  daily_reminders: "Daily reminders to eat, call your mother, etc.",
  voice_notes: "Voice notes that sound like a real Bubbe",
  eat_skinny_mode: "Proactive 'you look skinny' prompts",
  cooking_tips: "Classic Bubbe-style recipes and cooking advice",
  family_gossip: "Playful family gossip mode",
  oy_vey_responses: "'Oy vey, what are you doing?' responses",
  storytelling: "Storytelling mode with Bubbe tales",
  yiddish_dictionary_basic: "Basic Yiddish dictionary",
  yiddish_dictionary_full: "Full Yiddish dictionary with slang & insults",
  holiday_mode: "Full Jewish holiday reminders & blessings",
  local_news: "Local news from YOUR neighborhood with Bubbe's hot takes",
  local_sports: "Your local teams' scores with Bubbe's commentary",
  local_weather: "Hyper-local weather with guilt about not dressing warm",
  personalized_insights: "Bubbe knows your city like the back of her hand"
};

const PLAN_ORDER: PlanId[] = ["basic", "plus", "max", "supermax"];

/**
 * Check if a plan includes a specific feature
 */
export function planIncludesFeature(plan: PlanId, feature: FeatureKey): boolean {
  return PLAN_FEATURES[plan].includes(feature);
}

/**
 * Given a list of desired features from signup,
 * return the minimum plan that satisfies them all.
 */
export function getRequiredPlanForFeatures(features: FeatureKey[]): PlanId {
  for (const plan of PLAN_ORDER) {
    const ok = features.every((f) => planIncludesFeature(plan, f));
    if (ok) return plan;
  }
  // If something somehow falls through, default to max
  return "max";
}

/**
 * Given a plan, return human-readable info for UI.
 */
export function getPlanSummary(plan: PlanId) {
  return {
    id: plan,
    name: PLAN_NAMES[plan],
    description: PLAN_DESCRIPTIONS[plan],
    pricePerMonth: PLAN_PRICES[plan],
    features: PLAN_FEATURES[plan],
    featureDetails: PLAN_FEATURES[plan].map(f => ({
      key: f,
      description: FEATURE_DESCRIPTIONS[f]
    }))
  };
}

/**
 * Get all plans with their details (for pricing page)
 */
export function getAllPlansWithComparison() {
  const allFeatures = Object.keys(FEATURE_DESCRIPTIONS) as FeatureKey[];
  
  return PLAN_ORDER.map(plan => ({
    ...getPlanSummary(plan),
    featureComparison: allFeatures.map(feature => ({
      key: feature,
      description: FEATURE_DESCRIPTIONS[feature],
      included: planIncludesFeature(plan, feature)
    }))
  }));
}

/**
 * Check if a user can access a feature based on their plan
 * Returns true if allowed, or a Bubbe-style rejection message if not
 */
export function checkFeatureAccess(
  userPlan: PlanId,
  requestedFeature: FeatureKey
): { allowed: boolean; bubbeResponse?: string } {
  const allowed = planIncludesFeature(userPlan, requestedFeature);
  
  if (!allowed) {
    // Find the minimum plan that includes this feature
    const requiredPlan = getRequiredPlanForFeatures([requestedFeature]);
    const price = PLAN_PRICES[requiredPlan];
    
    // Bubbe-style rejection messages based on the feature
    const rejections: Record<FeatureKey, string> = {
      voice_notes: `Oy, you want to hear my voice? For ${price} dollars a month you can hear me kvetch all day! Such a bargain!`,
      holiday_mode: `What, you need ME to remind you about the holidays? Your mother didn't teach you? Upgrade to ${PLAN_NAMES[requiredPlan]} already!`,
      daily_reminders: `You need me to remind you to eat? Maybe if you paid ${price} dollars I'd care enough to nag you properly!`,
      eat_skinny_mode: `You look skinny but your wallet looks skinnier! ${price} dollars gets you all my food guilt!`,
      cooking_tips: `My secret recipes aren't FREE, bubbeleh! Fork over ${price} dollars or eat takeout forever!`,
      family_gossip: `Gossip costs money, sweetie. ${price} dollars and I'll tell you what your cousin REALLY did!`,
      yiddish_dictionary_full: `The good Yiddish words cost extra! ${price} dollars or you're stuck with 'oy vey'!`,
      local_news: `You want LOCAL news? What, you think I'm your personal newspaper? ${price} dollars for Super Max and I'll tell you EVERYTHING happening in your schmutz neighborhood!`,
      local_sports: `Your teams are all terrible anyway! But for ${price} dollars I'll tell you EXACTLY how terrible they are, with LOCAL scores!`,
      local_weather: `You can't look outside? For ${price} dollars I'll tell you the weather on YOUR street, not just the city. And I'll remind you to wear a jacket!`,
      personalized_insights: `Oh NOW you want me to know everything about your area? ${price} dollars for Super Max and I'll be your neighborhood yenta!`,
      advice_guilt: "Even I'm not THAT cruel to charge for guilt! Oh wait, this is free already!",
      yiddish_sayings: "Basic Yiddish is free, like my love - comes with strings attached!",
      oy_vey_responses: "Oy vey is free, just like your cheapskate attitude!",
      storytelling: "Stories are free, but the GOOD ones... that's another story!",
      yiddish_dictionary_basic: "You get some Yiddish for free. Be grateful, ungrateful child!"
    };
    
    return {
      allowed: false,
      bubbeResponse: rejections[requestedFeature] || 
        `Such expensive tastes on a ${PLAN_NAMES[userPlan]} plan! Maybe you upgrade to ${PLAN_NAMES[requiredPlan]} for ${price} dollars, nu?`
    };
  }
  
  return { allowed: true };
}

/**
 * Parse feature requirements from user message
 * This is a helper for detecting when users ask for premium features
 */
export function detectRequestedFeatures(message: string): FeatureKey[] {
  const features: FeatureKey[] = [];
  const lowerMessage = message.toLowerCase();
  
  // Voice-related
  if (lowerMessage.match(/\b(voice|speak|talk|hear|sound|audio)\b/)) {
    features.push("voice_notes");
  }
  
  // Holiday-related
  if (lowerMessage.match(/\b(shabbat|sabbath|pesach|passover|chanukah|hanukkah|rosh|yom kippur|sukkot|holiday)\b/)) {
    features.push("holiday_mode");
  }
  
  // Reminder-related
  if (lowerMessage.match(/\b(remind|reminder|daily|every day|notification)\b/)) {
    features.push("daily_reminders");
  }
  
  // Cooking-related
  if (lowerMessage.match(/\b(recipe|cook|cooking|bake|baking|kitchen|food prep)\b/)) {
    features.push("cooking_tips");
  }
  
  // Gossip-related
  if (lowerMessage.match(/\b(gossip|family news|family drama|cousin|relative)\b/)) {
    features.push("family_gossip");
  }
  
  // Yiddish dictionary
  if (lowerMessage.match(/\b(yiddish dictionary|translate|yiddish word|what does.*mean|yiddish slang)\b/)) {
    if (lowerMessage.match(/\b(full|complete|all|comprehensive|slang|insult)\b/)) {
      features.push("yiddish_dictionary_full");
    } else {
      features.push("yiddish_dictionary_basic");
    }
  }
  
  // Local content - news, sports, weather
  if (lowerMessage.match(/\b(local news|news in my area|what's happening in|neighborhood news|city news)\b/)) {
    features.push("local_news");
  }
  
  if (lowerMessage.match(/\b(local sports|my team|home team|city team|neighborhood team|lakers|dodgers|yankees|mets|bulls|bears)\b/)) {
    features.push("local_sports");
  }
  
  if (lowerMessage.match(/\b(local weather|weather in my area|weather near me|my neighborhood weather|weather on my street)\b/)) {
    features.push("local_weather");
  }
  
  // Personalized insights (catches general local requests)
  if (lowerMessage.match(/\b(my neighborhood|my area|my city|around here|near me|locally)\b/) &&
      !features.includes("local_news") && !features.includes("local_sports") && !features.includes("local_weather")) {
    features.push("personalized_insights");
  }
  
  return features;
}

/**
 * Generate a Bubbe-style upsell message
 */
export function generateUpsellMessage(
  currentPlan: PlanId,
  suggestedPlan: PlanId
): string {
  if (currentPlan === suggestedPlan) {
    return "You already have this plan, what more do you want from me?";
  }
  
  const price = PLAN_PRICES[suggestedPlan];
  const planName = PLAN_NAMES[suggestedPlan];
  
  const upsells = [
    `Listen bubbeleh, for just ${price} dollars a month you could have ${planName}. That's less than your fancy coffee!`,
    `Oy, still on the ${PLAN_NAMES[currentPlan]} plan? Your cousin upgraded to ${planName} already!`,
    `${price} dollars? That's nothing! You spend more on those video games you never play!`,
    `For ${price} dollars you get ${planName}. Such a deal! I should charge double!`,
    `You want the good stuff but won't pay ${price} dollars? What am I, a charity?`
  ];
  
  return upsells[Math.floor(Math.random() * upsells.length)];
}