⚔️CritForge
💳

Subscription UI

Subscription tier UI improvements and patterns

subscriptionstiersuipricing

Created: 2026-02-05 Status: In Development Priority: HIGH (Feature 1), MEDIUM (Features 2 & 3)


Overview

This document specifies three UI improvements to enhance subscription tier awareness and engagement in CritForge:

  1. Beta Tester Trial Countdown - Show remaining trial days with urgency indicators
  2. Founding Member Badge Toggle - Allow users to display/hide founding member status
  3. Tier Indicator in Header - Persistent visual reminder of current subscription tier

Feature 1: Beta Tester Trial Countdown

Context

Beta testers receive a 30-day trial with premium features and discount eligibility:

  • Friends: 40% lifetime discount
  • Public beta: 50% Year 1 discount

Currently, beta testers see only a static "Beta Tester" badge with no indication of time remaining. This creates risk of:

  • Unexpected trial expiration
  • Lost conversion opportunity (no urgency prompts)
  • Poor user experience (no actionable information)

Requirements

Functional:

  • Calculate days remaining from trial_expires_at timestamp
  • Display countdown only for subscription_tier === 'beta_tester'
  • Update display based on urgency thresholds
  • Handle edge cases: expired trials, null timestamps, invalid dates

Visual:

  • Clear, readable countdown text
  • Color-coded urgency states
  • Progress bar showing trial completion percentage
  • Non-intrusive placement (enhance existing card, don't overshadow)

Accessibility:

  • ARIA live region for countdown updates
  • Sufficient color contrast for all urgency states
  • Clear semantic structure for screen readers

Design Specification

Placement

Add countdown section BELOW the existing "Beta Tester" badge within the subscription card (lines 468-478 in settings/page.tsx):

Current Badge Section:
┌────────────────────────────────────────────────┐
│ Current Tier: Beta Tester                     │
│ ⚡ Beta Tester  "Early access..."              │
└────────────────────────────────────────────────┘

New Layout with Countdown:
┌────────────────────────────────────────────────┐
│ Current Tier: Beta Tester                     │
│ ⚡ Beta Tester  "Early access..."              │
│                                                 │
│ Trial Countdown (NEW SECTION):                 │
│ [Progress Bar] 23 days remaining                │
│ Your trial expires on Feb 28, 2026             │
└────────────────────────────────────────────────┘
Color Coding (Urgency States)
Days RemainingColor SchemeHex ColorsUsage
30-15 daysGreen (Success)text-green-500, bg-green-500/20, border-green-500/30Normal state, no urgency
14-8 daysBlue (Info)text-blue-500, bg-blue-500/20, border-blue-500/30Awareness prompt
7-1 daysAmber (Warning)text-amber-500, bg-amber-500/20, border-amber-500/30High urgency (matches password warning)
0 days (expired)Red (Destructive)text-destructive, bg-destructive/20, border-destructive/30Trial ended
Component Structure
// Pseudo-code structure
{isBetaTester && profile.trial_expires_at && (
  <div className="mt-4 space-y-2">
    {/* Countdown Header */}
    <div className="flex items-center justify-between">
      <span className="text-sm font-medium">Trial Status</span>
      <span className={`text-sm font-bold ${urgencyColor}`}>
        {daysRemaining} days remaining
      </span>
    </div>

    {/* Progress Bar */}
    <div className="w-full bg-muted rounded-full h-2">
      <div
        className={`h-2 rounded-full transition-all ${progressBarColor}`}
        style={{ width: `${percentComplete}%` }}
        role="progressbar"
        aria-valuenow={percentComplete}
        aria-valuemin={0}
        aria-valuemax={100}
        aria-label={`Trial ${percentComplete}% complete`}
      />
    </div>

    {/* Expiry Date & CTA */}
    <div className={`p-3 rounded-md border ${urgencyBgColor} ${urgencyBorderColor}`}>
      <p className="text-xs">
        Trial expires on {expiryDate}
      </p>

      {/* Conditional upgrade prompt for under 8 days */}
      {daysRemaining <= 7 && (
        <Button size="sm" className="mt-2">
          Upgrade Now
        </Button>
      )}
    </div>
  </div>
)}
Progress Bar Logic
// Calculate trial progress (0-100%)
const trialStart = new Date(profile.created_at);
const trialEnd = new Date(profile.trial_expires_at);
const now = new Date();

const totalTrialDuration = trialEnd.getTime() - trialStart.getTime();
const elapsed = now.getTime() - trialStart.getTime();
const percentComplete = Math.min(100, Math.max(0, (elapsed / totalTrialDuration) * 100));

// Invert for "remaining" progress bar (optional design choice)
const percentRemaining = 100 - percentComplete;
Urgency Messages
StateMessageIconCTA
30-15 days"Plenty of time to explore premium features"None
14-8 days"Your trial is halfway complete"ℹ️None
7-1 days"Your trial expires soon. Upgrade now to keep your content and features!"⚠️"Upgrade Now" button
0 days"Your trial has expired. Upgrade to restore access.""Upgrade Now" button
Edge Cases
  1. Null trial_expires_at: Don't render countdown (fallback to badge only)
  2. Invalid date: Log error, show generic "Beta Tester" badge
  3. Negative days (expired): Show "Trial Expired" with red styling
  4. Future date > 30 days: Cap display at "30+ days remaining"

Implementation Notes

File: /Applications/Development/Projects/CritForge/src/app/[locale]/settings/page.tsx

Changes:

  1. Add countdown calculation logic after profile load (line ~286)
  2. Add countdown UI below beta tester badge (line ~478)
  3. Extract urgency color logic to utility function
  4. Add ARIA live region for dynamic updates

Utility Function (suggest adding to /src/lib/utils/date-helpers.ts):

export interface TrialCountdown {
  daysRemaining: number;
  percentComplete: number;
  urgencyState: 'success' | 'info' | 'warning' | 'danger';
  expiryDate: string;
  isExpired: boolean;
}

export function calculateTrialCountdown(
  trialExpiresAt: string | null,
  createdAt: string
): TrialCountdown | null {
  if (!trialExpiresAt) return null;

  try {
    const now = new Date();
    const expiryDate = new Date(trialExpiresAt);
    const startDate = new Date(createdAt);

    // Calculate days remaining
    const msRemaining = expiryDate.getTime() - now.getTime();
    const daysRemaining = Math.ceil(msRemaining / (1000 * 60 * 60 * 24));

    // Calculate percent complete
    const totalDuration = expiryDate.getTime() - startDate.getTime();
    const elapsed = now.getTime() - startDate.getTime();
    const percentComplete = Math.min(100, Math.max(0, (elapsed / totalDuration) * 100));

    // Determine urgency state
    let urgencyState: 'success' | 'info' | 'warning' | 'danger';
    if (daysRemaining < 0) urgencyState = 'danger';
    else if (daysRemaining <= 7) urgencyState = 'warning';
    else if (daysRemaining <= 14) urgencyState = 'info';
    else urgencyState = 'success';

    return {
      daysRemaining: Math.max(0, daysRemaining),
      percentComplete: Math.round(percentComplete),
      urgencyState,
      expiryDate: expiryDate.toLocaleDateString('en-US', {
        year: 'numeric',
        month: 'long',
        day: 'numeric'
      }),
      isExpired: daysRemaining < 0
    };
  } catch (error) {
    console.error('[COUNTDOWN] Invalid date format:', error);
    return null;
  }
}

Testing Requirements

  1. Manual Testing:

    • Test all 4 urgency states (modify trial_expires_at in DB)
    • Verify progress bar accuracy
    • Check mobile responsive layout
    • Validate accessibility (screen reader, keyboard nav)
  2. E2E Test (add to /e2e/settings.spec.ts):

    • Beta tester sees countdown
    • Non-beta tester doesn't see countdown
    • Expired trial shows correct messaging
    • Upgrade button appears for under 8 days

Feature 2: Founding Member Badge Toggle

Context

First 100 paying customers receive "Founding Member" status with benefits:

  • Permanent badge on profile
  • Recognition in community
  • Potential future perks

Users should control whether this badge is publicly visible on their profile page.

Requirements

Functional:

  • Add badge_preferences JSONB column to profiles table
  • Create toggle UI in settings page
  • Update preferences via API call
  • Display badge on profile page (future feature, placeholder for now)

Database Schema:

  • Use flexible JSONB structure to support future badges
  • Default show_founding_member to false (opt-in)
  • Store badge grant timestamp in founding_member_granted_at

Business Logic:

  • Only show toggle if is_founding_member = true
  • Badge is awarded in Stripe webhook after first payment
  • Badge is permanent (cannot be removed, only hidden)

Database Migration

File: /Applications/Development/Projects/CritForge/src/lib/db/migrations/20260205_add_badge_preferences.sql

-- Add badge preferences column (JSONB for flexibility)
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS badge_preferences JSONB DEFAULT '{"show_founding_member": false}'::jsonb;

-- Add index for badge queries
CREATE INDEX IF NOT EXISTS idx_profiles_badge_preferences
ON profiles USING GIN (badge_preferences);

-- RLS Policy: Users can only update their own badge preferences
CREATE POLICY "Users can update own badge preferences"
ON profiles
FOR UPDATE
USING (auth.uid() = id)
WITH CHECK (auth.uid() = id);

-- Validation: Ensure badge_preferences has correct structure
ALTER TABLE profiles
ADD CONSTRAINT IF NOT EXISTS check_badge_preferences_format
CHECK (
  badge_preferences ? 'show_founding_member' AND
  (badge_preferences->>'show_founding_member')::boolean IS NOT NULL
);

-- Backfill existing profiles with default structure
UPDATE profiles
SET badge_preferences = '{"show_founding_member": false}'::jsonb
WHERE badge_preferences IS NULL
  OR NOT (badge_preferences ? 'show_founding_member');

Design Specification

Placement

Add new section AFTER the Display Preferences card (after line 620 in settings/page.tsx):

┌─────────────────────────────────────────────────┐
│ 🏅 Badges & Recognition                         │
│                                                  │
│ Manage which badges appear on your profile       │
│                                                  │
│ Founding Member Badge                            │
│ [Toggle Switch: OFF]                             │
│                                                  │
│ You are one of the first 100 paying             │
│ subscribers! Show this badge on your profile    │
│ to celebrate your early support.                │
│                                                  │
│ Preview: [🏅 Founding Member] ← Badge design    │
└─────────────────────────────────────────────────┘
Conditional Rendering

Only show this card if:

const isFoundingMember = profile.is_founding_member;

if (!isFoundingMember) return null;
Component Structure
{/* Badges & Recognition (Founding Members Only) */}
{profile.is_founding_member && (
  <DndCard>
    <DndCardHeader>
      <div className="flex items-center gap-2">
        <Award className="h-5 w-5 text-dragon-500" /> {/* lucide-react icon */}
        <DndCardTitle>Badges & Recognition</DndCardTitle>
      </div>
      <DndCardDescription>
        Manage which badges appear on your public profile
      </DndCardDescription>
    </DndCardHeader>

    <DndCardContent className="space-y-4">
      {/* Founding Member Toggle */}
      <div className="flex items-start justify-between gap-4">
        <div className="flex-1 space-y-2">
          <div className="flex items-center gap-2">
            <Label htmlFor="founding-member-toggle" className="font-medium">
              Founding Member Badge
            </Label>
            {/* Badge Preview Inline */}
            <Badge className="bg-gold-base/20 text-gold-base border-gold-base/30">
              <Award className="h-3 w-3 mr-1" />
              Founding Member
            </Badge>
          </div>

          <p className="text-sm text-muted-foreground">
            You are one of the first 100 paying subscribers!
            Show this badge on your profile to celebrate your early support.
          </p>

          {/* Grant Date */}
          {profile.founding_member_granted_at && (
            <p className="text-xs text-muted-foreground">
              Earned on {new Date(profile.founding_member_granted_at).toLocaleDateString()}
            </p>
          )}
        </div>

        {/* Toggle Switch */}
        <Switch
          id="founding-member-toggle"
          checked={showFoundingMember}
          onCheckedChange={handleToggleFoundingMember}
          disabled={isUpdatingBadge}
          aria-label="Show Founding Member badge on profile"
        />
      </div>

      {/* Info Banner */}
      <div className="flex items-start gap-2 p-3 bg-blue-500/10 border border-blue-500/30 rounded-md">
        <Info className="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
        <p className="text-sm text-muted-foreground">
          Your badge will appear on your public profile page and in community features.
          This badge is permanent and cannot be removed, but you can hide it anytime.
        </p>
      </div>
    </DndCardContent>
  </DndCard>
)}

API Endpoint

File: /Applications/Development/Projects/CritForge/src/app/api/user/badge-preferences/route.ts

import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { z } from 'zod';

const BadgePreferencesSchema = z.object({
  show_founding_member: z.boolean(),
});

export async function PATCH(request: NextRequest) {
  try {
    const supabase = await createClient();

    // Authenticate user
    const { data: { user }, error: authError } = await supabase.auth.getUser();
    if (authError || !user) {
      return NextResponse.json(
        { error: 'Unauthorized' },
        { status: 401 }
      );
    }

    // Parse request body
    const body = await request.json();
    const { show_founding_member } = BadgePreferencesSchema.parse(body);

    // Update badge preferences (merge with existing)
    const { data: profile, error: updateError } = await supabase
      .from('profiles')
      .update({
        badge_preferences: {
          show_founding_member
        }
      })
      .eq('id', user.id)
      .select('badge_preferences')
      .single();

    if (updateError) {
      console.error('[BADGE_PREFS] Update failed:', updateError);
      return NextResponse.json(
        { error: 'Failed to update badge preferences' },
        { status: 500 }
      );
    }

    return NextResponse.json({
      success: true,
      badge_preferences: profile.badge_preferences
    });

  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json(
        { error: 'Invalid request body', details: error.errors },
        { status: 400 }
      );
    }

    console.error('[BADGE_PREFS] Unexpected error:', error);
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

Client-Side Logic

// In settings/page.tsx
const [showFoundingMember, setShowFoundingMember] = useState(false);
const [isUpdatingBadge, setIsUpdatingBadge] = useState(false);

// Load badge preference
useEffect(() => {
  if (profile?.badge_preferences) {
    setShowFoundingMember(
      profile.badge_preferences.show_founding_member ?? false
    );
  }
}, [profile]);

// Handle toggle
const handleToggleFoundingMember = async (checked: boolean) => {
  setIsUpdatingBadge(true);

  try {
    const response = await fetch('/api/user/badge-preferences', {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ show_founding_member: checked })
    });

    if (!response.ok) {
      throw new Error('Failed to update badge preferences');
    }

    setShowFoundingMember(checked);
    toast.success(
      checked
        ? 'Founding Member badge enabled!'
        : 'Founding Member badge hidden'
    );

  } catch (error) {
    console.error('[BADGE_TOGGLE] Error:', error);
    toast.error('Failed to update badge preference');
    setShowFoundingMember(!checked); // Revert on error
  } finally {
    setIsUpdatingBadge(false);
  }
};

Testing Requirements

  1. Unit Tests:

    • API endpoint validates schema
    • RLS policies prevent unauthorized updates
    • Toggle state persists across page reloads
  2. E2E Tests:

    • Founding members see badge section
    • Non-founding members don't see section
    • Toggle updates database correctly
    • Badge preview matches actual badge design

Feature 3: Tier Indicator in Header

Context

Users currently have no persistent visual reminder of their subscription tier. This causes:

  • Confusion about feature access ("Why can't I do X?")
  • Missed upgrade opportunities (users forget they're on trial)
  • Poor tier awareness across the app

Requirements

Functional:

  • Display current tier in header/user menu
  • Visible on all pages (persistent)
  • Color-coded by tier type
  • Tooltip with tier benefits on hover

Visual:

  • Subtle, non-intrusive indicator
  • Consistent with existing header design
  • Mobile-responsive (different layout for small screens)

Performance:

  • No extra database queries (use existing user session data)
  • Tier info cached in UserMenu server component

Design Specification

Placement Options

Option A: Badge in User Dropdown (RECOMMENDED)

Pros:

  • No header real estate impact
  • Already shows user email, logical place for tier
  • Easy to implement

Cons:

  • Not visible until dropdown opened
  • Less persistent awareness
User Menu Dropdown:
┌──────────────────────────────┐
│ Account                      │
│ [email protected]             │
│ ⚡ Beta Tester ← NEW BADGE   │
├──────────────────────────────┤
│ ⚙️ Settings                  │
│ 🛡️ Admin Panel              │
├──────────────────────────────┤
│ 🚪 Log Out                   │
└──────────────────────────────┘

Option B: Colored Dot on Avatar

Pros:

  • Always visible
  • Subtle, doesn't clutter UI

Cons:

  • Hard to interpret (what does color mean?)
  • Accessibility issues (color alone)

Option C: Mini Badge Next to Avatar

Pros:

  • Clear tier indication
  • Always visible

Cons:

  • Takes header space
  • May look cluttered on mobile

RECOMMENDATION: Option A (badge in dropdown) for MVP, consider Option C for v2 based on user feedback.

Implementation: Option A (Dropdown Badge)

File: /Applications/Development/Projects/CritForge/src/components/user-menu.tsx

Changes:

  1. Fetch subscription tier from profile
  2. Add tier badge after email in dropdown label
  3. Add tier-specific icon and color
// user-menu.tsx modifications

// Add tier icons
import { Crown, Zap, Clock, Sparkles, CreditCard } from "lucide-react";

export async function UserMenu() {
  const supabase = await createClient();
  const t = await getTranslations('common.userMenu');

  const { data: { user } } = await supabase.auth.getUser();
  if (!user) {
    return (
      <Link href="/login">
        <Button variant="ghost" size="sm" className="min-h-[44px] min-w-[44px]">
          {t('signIn')}
        </Button>
      </Link>
    );
  }

  // NEW: Fetch subscription tier
  const { data: profile } = await supabase
    .from('profiles')
    .select('subscription_tier, trial_expires_at')
    .eq('id', user.id)
    .single();

  const subscriptionTier = profile?.subscription_tier || 'free';

  // NEW: Tier badge configuration
  const tierConfig = {
    beta_tester: {
      label: 'Beta Tester',
      icon: Zap,
      className: 'bg-dragon-500/20 text-dragon-500 border-dragon-500/30'
    },
    trial: {
      label: 'Trial',
      icon: Clock,
      className: 'bg-blue-500/20 text-blue-500 border-blue-500/30'
    },
    premium: {
      label: 'Premium',
      icon: Crown,
      className: 'bg-gold-base/20 text-gold-base border-gold-base/30'
    },
    party_plan: {
      label: 'Party Plan',
      icon: Crown,
      className: 'bg-green-500/20 text-green-500 border-green-500/30'
    },
    pay_as_you_go: {
      label: 'Pay-As-You-Go',
      icon: CreditCard,
      className: 'bg-gray-500/20 text-gray-500 border-gray-500/30'
    },
    free: {
      label: 'Free',
      icon: Sparkles,
      className: 'bg-muted/50 text-muted-foreground border-muted'
    },
    expired_trial: {
      label: 'Trial Expired',
      icon: Clock,
      className: 'bg-destructive/20 text-destructive border-destructive/30'
    }
  };

  const tierInfo = tierConfig[subscriptionTier as keyof typeof tierConfig] || tierConfig.free;
  const TierIcon = tierInfo.icon;

  const isSuperAdmin = await checkIsSuperAdmin();

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button
          variant="ghost"
          size="sm"
          className="relative min-h-[44px] min-w-[44px] rounded-full"
          aria-label={t('userMenuAriaLabel')}
        >
          <User className="h-5 w-5" />
        </Button>
      </DropdownMenuTrigger>

      <DropdownMenuContent align="end" className="w-56">
        <DropdownMenuLabel className="font-normal">
          <div className="flex flex-col space-y-2">
            <p className="text-sm font-medium leading-none">{t('account')}</p>
            <p className="text-xs leading-none text-muted-foreground">{user.email}</p>

            {/* NEW: Tier Badge */}
            <Badge className={`${tierInfo.className} w-fit`}>
              <TierIcon className="h-3 w-3 mr-1" />
              {tierInfo.label}
            </Badge>
          </div>
        </DropdownMenuLabel>

        <DropdownMenuSeparator />

        {/* Rest of menu items... */}
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

Tier Color Palette

TierIconColorHexRationale
Beta Tester⚡ ZapDragon Red/Purpledragon-500Premium access, special status
Trial🕐 ClockBlueblue-500Temporary, time-limited
Premium👑 CrownGoldgold-basePremium tier, paid
Party Plan👑 CrownGreengreen-500Group tier, collaborative
Pay-As-You-Go💳 CreditCardGraygray-500Neutral, transactional
Free✨ SparklesMutedmuted-foregroundBase tier
Expired Trial🕐 ClockReddestructiveRequires action

Tooltip Enhancement (Optional v2 Feature)

Add tooltip on hover showing tier benefits:

<TooltipProvider>
  <Tooltip>
    <TooltipTrigger asChild>
      <Badge className={tierInfo.className}>
        <TierIcon className="h-3 w-3 mr-1" />
        {tierInfo.label}
      </Badge>
    </TooltipTrigger>
    <TooltipContent side="left" className="max-w-xs">
      <div className="space-y-1">
        <p className="font-medium">{tierInfo.label}</p>
        <ul className="text-xs space-y-0.5">
          {tierInfo.benefits.map((benefit, i) => (
            <li key={i}>✓ {benefit}</li>
          ))}
        </ul>
      </div>
    </TooltipContent>
  </Tooltip>
</TooltipProvider>

Testing Requirements

  1. Visual Regression:

    • Test all 7 tier states (beta_tester, trial, premium, party_plan, pay_as_you_go, free, expired_trial)
    • Verify colors match design system
    • Check mobile responsiveness
  2. E2E Tests:

    • User sees correct tier in dropdown
    • Tier updates after subscription change
    • Colors and icons match tier

Implementation Priority

  1. Phase 1 (This Sprint): Beta Tester Countdown

    • High user impact (retention)
    • Clear requirements
    • Single component change
  2. Phase 2 (Next Sprint): Founding Member Badge

    • Database migration needed
    • New API endpoint
    • Feature flag for rollout
  3. Phase 3 (Future): Tier Indicator

    • QOL improvement
    • Low complexity
    • Can iterate based on feedback

Accessibility Checklist

  • All color states meet WCAG AA contrast (4.5:1)
  • ARIA live regions for dynamic countdown updates
  • Keyboard navigation for all interactive elements
  • Screen reader labels for icons and badges
  • Focus indicators visible on all controls
  • Touch targets ≥44×44px on mobile
  • Color not sole indicator of state (use icons + text)

Mobile Responsiveness Notes

Beta Tester Countdown:

  • Stack progress bar and text vertically on under 640px screens
  • Reduce font size for expiry date on mobile
  • Ensure CTA button is full-width on mobile

Badge Toggle:

  • Stack toggle and description vertically on under 640px
  • Ensure toggle switch is ≥44px touch target

Tier Indicator:

  • Same badge style in dropdown on all screen sizes
  • Truncate long tier names if needed

Future Enhancements (Out of Scope)

  1. Animated Countdown: Real-time countdown timer (updates every minute)
  2. Trial Extension: Allow GMs to request trial extension (manual approval)
  3. Badge Collection Page: Dedicated page showing all earned badges
  4. Tier Comparison Modal: Click tier badge → see tier comparison chart
  5. Custom Badges: Admin-granted badges for contributors, streamers, etc.

References

  • Settings Page: /Applications/Development/Projects/CritForge/src/app/[locale]/settings/page.tsx
  • User Menu: /Applications/Development/Projects/CritForge/src/components/user-menu.tsx
  • Database Schema: /Applications/Development/Projects/CritForge/src/types/database.ts
  • Pricing Strategy: /Applications/Development/Projects/CritForge/docs/business/pricing-strategy.md
  • Design System: /Applications/Development/Projects/CritForge/docs/architecture/design-system.md