Purchase-Flow Analyse

MOC - Map of Content

Überblick

Der aktuelle Boost-Purchase-Flow nutzt Stripe Checkout Sessions. Dies ist ein solider Standard-Ansatz, hat aber mehrere Friction Points die zur niedrigen Conversion führen. Dieser Dokument analysiert die aktuelle Implementation und schlägt konkrete Optimierungen vor.


Aktueller Flow: Stripe Checkout

graph LR
    A["User öffnet
Song Card"] -->|Sieht Boost Button| B["Klickt
Boost Button"] B -->|Frontend Call| C["createCheckoutSession
Netlify Function"] C -->|Erstellt Session| D["Stripe Checkout
URL"] D -->|Redirect| E["User verlässt
die App"] E -->|Zahlt bei Stripe| F["Stripe Process
Payment"] F -->|Webhook POST| G["stripeWebhook
Netlify Function"] G -->|Update DB| H["Queue Position
Update"] H -->|Redirect Back| I["Supabase
Realtime Update"] I -->|User sieht
Position Change| J["Song Boost
Erfolgreich"]

Code Structure (vermutlich)

Frontend (app/web/src/components/viewer/):

// BoostButton.jsx
const handleBoost = async () => {
  const { data } = await fetch('/.netlify/functions/createCheckoutSession', {
    method: 'POST',
    body: JSON.stringify({ songId, userId, amount: 5.00 })
  });

  // Redirect to Stripe
  window.location.href = data.url;
}

Backend (Netlify Function):

// functions/createCheckoutSession.js
export const handler = async (event) => {
  const session = await stripe.checkout.sessions.create({
    payment_method_types: ['card'],
    line_items: [{
      price: 'price_XXX',
      quantity: 1,
    }],
    mode: 'payment',
    success_url: 'https://breakout.music/session?boost=success',
    cancel_url: 'https://breakout.music/session',
  });

  return { statusCode: 200, body: JSON.stringify({ url: session.url }) };
}
// functions/stripeWebhook.js
export const handler = async (event) => {
  const sig = event.headers['stripe-signature'];
  const stripeEvent = stripe.webhooks.constructEvent(
    event.body,
    sig,
    STRIPE_WEBHOOK_SECRET
  );

  if (stripeEvent.type === 'checkout.session.completed') {
    const { metadata: { songId, userId } } = stripeEvent.data.object;

    // Update queue position in PostgreSQL
    await supabase.from('song_boosts').insert({
      song_id: songId,
      user_id: userId,
      amount: 5.00,
      created_at: new Date()
    });

    // Trigger Realtime Update
    supabase.channel(`song:${songId}`).send({
      type: 'broadcast',
      event: 'boost_added',
      payload: { boost_count: newTotal }
    });
  }
}

Friction Points Analyse

🔴 Kritische Friction Points

1. App-Verlassen = Conversion Killer

Problem: User klickt Boost → wird zu Stripe redirected → verlässt App → möchte nicht zurückkommen
Warum schlecht:

Conversion Impact: -30% bis -50% (Industry Standard: Embedded Payment = 20-30% höher Conversion)

Lösung: Stripe Elements für In-App Payment

2. Keine "One-Click" Repeat Purchases

Problem: User boosted einmal, nächstes Mal muss er wieder Stripe-Formular ausfüllen
Warum schlecht:

Conversion Impact: -20% für Repeat Purchases

Lösung: Save Payment Method, Show Saved Cards, "1-Click Boost" Button

3. Keine Preis-Verankerung oder Urgency

Problem: User sieht einfach "Boost" Button ohne Context
Warum schlecht:

Conversion Impact: -25% (Menschen treffen Entscheidungen besser mit Context)

Lösung: Pre-Checkout Screen mit Pricing, Urgency, Social Proof

4. Boost ist "Optional" - Nicht Core

Problem: Boost Button ist vielleicht unauffällig neben Vote Button
Warum schlecht:

Conversion Impact: -40% (zu unauffällig)

Lösung: Boost Button als Primary, Vote als Secondary. "Boost = Pay to Move Up Queue"

5. Keine Positional Preview

Problem: User zahlt 5€ und weiß nicht ob Song von Position 47 zu Position 32 wechselt
Warum schlecht:

Conversion Impact: -15% (Unsicherheit reduziert Purchase Intent)

Lösung: Vor Checkout: "Boost wird Song von #47 zu #32 bewegen (Rechner zeigen)"

6. Queue-Funktion unklar

Problem: Neue User verstehen nicht dass man "Boosts" kaufen kann um nach oben zu kommen
Warum schlecht:

Conversion Impact: -50% (Feature wird einfach nicht genutzt)

Lösung: Onboarding erklärt Boost-System, Queue-Legende sichtbar


🟡 Mittlere Friction Points

7. Keine Bundle Pricing

Problem: Nur Single-Boost-Preis verfügbar (z.B. €5)
Warum suboptimal:

Lösung: Tiered Pricing

8. Keine Dynamic Pricing

Problem: €5 Boost ist immer €5, egal wie viele andere gerade boosten
Warum suboptimal:

Lösung: Dynamic Pricing basierend auf Queue-Größe

Queue >= 100 Boosts in letzten 24h → Preise +20%
Queue <= 10 Boosts → Preise -10%

9. Keine Social Proof im Checkout

Problem: User sieht Stripe Form, keine Indication dass viele andere das auch machen
Warum suboptimal:

Lösung: Pre-Checkout Screen zeigt "200 people boosted in this session"

10. Keine Urgency Signals

Problem: "Boost jederzeit" - keine Deadline
Warum suboptimal:

Lösung: "Queue closes in 15 minutes" oder "Last 5 songs in queue" messaging


🟢 Minor Friction Points

11. Keine Error Recovery

Problem: Wenn User Checkout abbricht, zurück zur App und... was jetzt?
Lösung: "You almost completed your boost! Try again?" Message

12. Keine Mobile Optimization

Problem: Stripe Checkout kann auf Mobile suboptimal sein
Lösung: Test auf iPhone/Android, eventuell Stripe Mobile Optimized Checkout


Verbesserte Flow: Stripe Elements + In-App Checkout

graph LR
    A["User öffnet
Song Card"] -->|Sieht Boost Button
mit Preis| B["Klickt Boost
Button"] B -->|Modal öffnet| C["Pre-Checkout
Screen"] C -->|Zeigt:
Position Preview
Price Anchoring
Social Proof
Urgency| D["User sieht
Boost von #47→#32
€5"] D -->|Weiter| E["Stripe Elements
Card Form"] E -->|In-App Payment
Keine Redirect| F["Processing..."] F -->|Webhook Success| G["Realtime Update
via Supabase"] G -->|Konfetti +
Success Message| H["Boost Complete
User bleibt in App"] H -->|Optional: Save
Payment Method| I["Nächstes Mal:
1-Click Boost"]

Implementation Details

Frontend Komponente (neuer Flow):

// BoostModal.jsx
export const BoostModal = ({ song, currentPosition, onClose }) => {
  const [selectedTier, setSelectedTier] = useState('medium');
  const [cardElement, setCardElement] = useState(null);
  const [isProcessing, setIsProcessing] = useState(false);

  // Calculate Position Preview
  const positionDelta = {
    small: -1,
    medium: -3,
    large: -5
  };
  const newPosition = Math.max(1, currentPosition + positionDelta[selectedTier]);

  const handleSubmit = async (e) => {
    e.preventDefault();
    setIsProcessing(true);

    try {
      // 1. Create Payment Intent (not Session)
      const { clientSecret } = await fetch('/.netlify/functions/createPaymentIntent', {
        method: 'POST',
        body: JSON.stringify({
          songId: song.id,
          userId: user.id,
          amount: PRICING[selectedTier],
          savePaymentMethod: true
        })
      }).then(r => r.json());

      // 2. Confirm Payment with Card Element (no redirect!)
      const { paymentIntent, error } = await stripe.confirmCardPayment(clientSecret, {
        payment_method: {
          card: cardElement,
          billing_details: { name: user.name, email: user.email }
        }
      });

      if (error) throw new Error(error.message);

      // 3. Success! Show Celebration
      showConfetti(); // celebratory animation

      // 4. Realtime subscription handles queue update
      // Webhook telah trigger, Supabase akan broadcast

      setTimeout(() => onClose(), 2000);

    } catch (err) {
      showError(err.message);
    } finally {
      setIsProcessing(false);
    }
  };

  return (
    <Modal isOpen={true} onClose={onClose}>
      <h2>Boost "{song.title}"</h2>

      {/* Social Proof */}
      <div className="bg-green-50 p-3 rounded mb-4">
        ✨ 247 people boosted songs in this session today
      </div>

      {/* Urgency Signal */}
      <div className="bg-yellow-50 p-3 rounded mb-4">
        ⏰ Queue closes in 12 minutes
      </div>

      {/* Pricing Tiers */}
      <div className="grid grid-cols-3 gap-2 mb-4">
        {['small', 'medium', 'large'].map(tier => (
          <button
            key={tier}
            onClick={() => setSelectedTier(tier)}
            className={`p-3 border-2 rounded ${
              selectedTier === tier ? 'border-blue-500 bg-blue-50' : 'border-gray-200'
            }`}
          >
            <div className="font-bold">{PRICING[tier]}€</div>
            <div className="text-xs">+{positionDelta[tier]} Pos</div>
          </button>
        ))}
      </div>

      {/* Position Preview */}
      <div className="mb-4">
        <div className="text-sm text-gray-600">Your impact:</div>
        <div className="text-lg font-bold">
          #{currentPosition} → #{newPosition}
        </div>
      </div>

      {/* Payment Form */}
      <form onSubmit={handleSubmit}>
        <CardElement
          onChange={(e) => setCardElement(e.complete ? e.element : null)}
          className="mb-4"
        />

        <label className="flex items-center gap-2 mb-4">
          <input type="checkbox" defaultChecked />
          <span className="text-sm">Save for next time (faster checkout)</span>
        </label>

        <button
          type="submit"
          disabled={isProcessing || !cardElement}
          className="w-full bg-blue-500 text-white py-2 rounded font-bold disabled:bg-gray-300"
        >
          {isProcessing ? 'Processing...' : `Boost for €${PRICING[selectedTier]}`}
        </button>
      </form>
    </Modal>
  );
};

Backend (neue Payment Intent Function):

// functions/createPaymentIntent.js
export const handler = async (event) => {
  const { songId, userId, amount, savePaymentMethod } = JSON.parse(event.body);

  const paymentIntent = await stripe.paymentIntents.create({
    amount: Math.round(amount * 100), // Convert to cents
    currency: 'eur',
    payment_method_types: ['card'],
    metadata: {
      songId,
      userId,
      type: 'boost'
    },
    // Save payment method for one-click checkout
    setup_future_usage: savePaymentMethod ? 'on_session' : undefined
  });

  return {
    statusCode: 200,
    body: JSON.stringify({ clientSecret: paymentIntent.client_secret })
  };
};

Webhook bleibt ähnlich, aber triggered durch confirmCardPayment statt Session:

// functions/stripeWebhook.js
export const handler = async (event) => {
  const sig = event.headers['stripe-signature'];
  const stripeEvent = stripe.webhooks.constructEvent(
    event.body,
    sig,
    STRIPE_WEBHOOK_SECRET
  );

  if (stripeEvent.type === 'payment_intent.succeeded') {
    const { metadata: { songId, userId } } = stripeEvent.data.object;

    // Record boost
    await supabase.from('song_boosts').insert({
      song_id: songId,
      user_id: userId,
      amount: stripeEvent.data.object.amount / 100,
      payment_intent_id: stripeEvent.data.object.id,
      created_at: new Date()
    });

    // Realtime broadcast to all listeners
    supabase.channel(`song:${songId}`).send({
      type: 'broadcast',
      event: 'boost_added',
      payload: {
        boost_count: newTotal,
        newPosition: calculateNewPosition(songId)
      }
    });
  }
};

Revenue Optimierungs-Strategien

1. A/B Testing Preise

Hypothese 1: Tiered Pricing konvertiert besser als Single Price

Variant A (Control): €5 Boost only
Variant B (Test): €2 / €5 / €10 Tiers

Metrik: Conversion Rate (% of viewers who boost)
Erwartet: +15-25% höher

Hypothese 2: Lower price floor konvertiert mehr

Variant A: €2 / €5 / €10
Variant B: €1 / €3 / €7

Metrik: Revenue per viewer (auch wenn Conversion höher, kann ARPU sinken)
Erwartet: Variant A better

2. Dynamic Pricing nach Queue-Volumen

// calculateBoostPrice.js
export const getBoostPrice = async (sessionId) => {
  const boostCount = await countBoostsLast24h(sessionId);

  if (boostCount >= 100) return { small: 2.50, med: 6.25, large: 12.50 }; // +25%
  if (boostCount >= 50) return { small: 2.20, med: 5.50, large: 11.00 }; // +10%
  if (boostCount >= 20) return { small: 2.00, med: 5.00, large: 10.00 }; // baseline

  return { small: 1.80, med: 4.50, large: 9.00 }; // -10% (encouragement)
};

3. Bundle Discounts

Buy 1 Boost: €5
Buy 3 Boosts: €14 (save €1)
Buy 5 Boosts: €20 (save €5) ← Sweetspot

Psychologischer Effekt: "Save €5" ist motivierender als "20% discount"

4. Urgency & FOMO

// In BoostModal
const timeUntilQueueClose = calculateTimeUntilQueueClose(session);

if (timeUntilQueueClose < 15 * 60 * 1000) { // < 15 minutes
  return <UrgencyBadge text={`Queue closes in ${minutes}m`} />;
}

if (songsInQueue < 10) {
  return <UrgencyBadge text="Last 10 songs - limited spots!" />;
}

5. Gamification: "Boost Streaks"

Wenn User 3 Sessions in a row boosted → nächster Boost -20%

const userBoostStreak = await getConsecutiveSessionsWithBoost(userId);
if (userBoostStreak >= 3) {
  discountMultiplier = 0.8; // 20% off
  showBadge("🔥 Boost Streak: 3 Sessions");
}

Sicherheits-Überlegungen

Webhook Verification

✅ Stripe Signatur muss validiert sein
✅ Webhook muss idempotent sein (mehrfaches Trigger = selbes Resultat)
✅ Race Condition handling: 2 Boosts gleichzeitig = korrekte Position Update

// functions/stripeWebhook.js - Idempotency
export const handler = async (event) => {
  const paymentIntentId = stripeEvent.data.object.id;

  // Check if we've already processed this
  const existing = await supabase
    .from('song_boosts')
    .select('id')
    .eq('payment_intent_id', paymentIntentId)
    .single();

  if (existing) {
    // Already processed, return success (don't duplicate)
    return { statusCode: 200, body: 'Webhook already processed' };
  }

  // Process new boost...
};

Token/Session Security


Mögliche Komplikationen & Edge Cases

1. Saved Payment Methods - PCI Compliance

Wenn wir Payment Methods speichern wollen:

Impleentation: Stripe Elements mit setup_future_usage: 'on_session'

2. Failed Webhooks

Wenn Webhook fehlschlägt (Network Error, etc.):

3. Concurrency Problem

Wenn 100 User gleichzeitig denselben Song boosten:

Solution: PostgreSQL Row Locking oder Transactionen

BEGIN TRANSACTION;
LOCK TABLE song_boosts IN EXCLUSIVE MODE;
-- Calculate position
-- Insert boost
-- Update queue position
COMMIT;

4. Chargebacks & Fraud


Metriken zum Tracken

Metrik Target Überprüfung
Boost Conversion Rate > 2% nach 2 Wochen daily
Repeat Boost Rate > 30% of users boost 2+ times weekly
Average Boost Value €5+ weekly
Boost Revenue / Session €50+ durchschnittlich weekly
Payment Error Rate < 0,5% daily
Bounce Rate (Stripe Checkout Abbruch) < 40% weekly
Saved Payment Method Usage > 60% Repeat Purchases weekly

Implementation Timeline

Phase Komponenten Effort Timeline
1. Current State Stripe Checkout Sessions (existing) 0 Now
2. Quick Win Add Pre-Checkout Screen (position preview + social proof) 2 days Week 1
3. Elements Stripe Elements in-app payment 3 days Week 1-2
4. Saved Cards Payment Method Saving + one-click 2 days Week 2
5. Pricing Optimization Dynamic pricing + A/B testing 2 days Week 2
6. Analytics Boost metrics tracking 1 day Week 3

Fazit

Aktueller Flow ist solid aber konvertiert zu niedrig.

Quick Wins (sofort machen):

  1. Add Position Preview vor Checkout (15 min change)
  2. Add Social Proof ("247 boosts today") (15 min change)
  3. Add Urgency ("Queue closes in 12m") (15 min change)
  4. Tiered Pricing (€2/€5/€10) (2 hours change)

Big Wins (nächste 2 Wochen):

  1. Stripe Elements für in-app payment (3 days)
  2. Saved payment methods (2 days)
  3. Dynamic pricing (1 day)
  4. A/B testing setup (1 day)

Expected Impact: 2-3x höhere Boost-Conversion, 2x höheres Average-Revenue-per-Boost


MOC - Map of Content | Aktueller Stand der App | Revenue-Streams | Quick Wins | Strategische Entscheidungen