mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
feat(cards): Phase ζ.1 — Paid decks via mana-credits
Server (cards-server):
- lib/credits.ts: thin internal-API client for mana-credits
(reserve / commit / refund-reservation / grant). Service-to-
service via X-Service-Key. Throws InsufficientCreditsError
separately so the buy flow can branch on UX.
- services/purchases.ts: 4-step purchase pipeline: reserve →
insert deck_purchases row → commit reservation → grant
author share + insert author_payouts. Idempotent on
(buyer, deck) so a refresh-spam-click can't double-charge.
Verified-mana authors get the 90/10 split, others 80/20
(already in config). Refunds intentionally out of scope —
see MARKETPLACE_PLAN §13a.
- routes/purchases.ts: POST /v1/decks/:slug/purchase,
GET /v1/me/purchases, GET /v1/authors/me/payouts.
- decks.bySlug now returns hasPurchased (null when anonymous,
bool when authed) so the deck-detail page can pick the right
CTA.
- subscriptions.subscribe now blocks paid decks unless the
caller has a non-refunded purchase row (owner exempt for
testing).
- Notify: author gets a "Verkauf"-Email at grant time, with a
deterministic externalId for dedup.
Frontend (cards-web):
- /d/<slug> shows "Kaufen für N 💎" instead of "Abonnieren"
when paid + not yet bought; flips to subscribe path once
purchased.
- /me/purchases page listing buyer history + (when present)
author-payout history. Linked from the top nav.
This commit is contained in:
parent
4fcc15737f
commit
5dbc9ace2d
10 changed files with 627 additions and 12 deletions
|
|
@ -133,10 +133,11 @@ export const cardsApi = {
|
|||
priceCredits?: number;
|
||||
}) => request<PublicDeck>('/v1/decks', { method: 'POST', body: input }),
|
||||
bySlug: (slug: string) =>
|
||||
request<{ deck: PublicDeck; latestVersion: PublicDeckVersion | null }>(
|
||||
`/v1/decks/${encodeURIComponent(slug)}`,
|
||||
{ auth: 'optional' }
|
||||
),
|
||||
request<{
|
||||
deck: PublicDeck;
|
||||
latestVersion: PublicDeckVersion | null;
|
||||
hasPurchased: boolean | null;
|
||||
}>(`/v1/decks/${encodeURIComponent(slug)}`, { auth: 'optional' }),
|
||||
publish: (
|
||||
slug: string,
|
||||
input: {
|
||||
|
|
@ -240,6 +241,17 @@ export const cardsApi = {
|
|||
reject: (id: string) =>
|
||||
request<{ ok: true }>(`/v1/pull-requests/${id}/reject`, { method: 'POST' }),
|
||||
},
|
||||
purchases: {
|
||||
buy: (deckSlug: string) =>
|
||||
request<PurchaseResult>(`/v1/decks/${encodeURIComponent(deckSlug)}/purchase`, {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
}),
|
||||
listMine: () => request<BuyerPurchase[]>('/v1/me/purchases'),
|
||||
},
|
||||
payouts: {
|
||||
listMine: () => request<AuthorPayout[]>('/v1/authors/me/payouts'),
|
||||
},
|
||||
discussions: {
|
||||
countsForDeck: (deckSlug: string) =>
|
||||
request<Record<string, number>>(
|
||||
|
|
@ -386,6 +398,49 @@ export interface PullRequest {
|
|||
resolvedAt: string | null;
|
||||
}
|
||||
|
||||
export interface PurchaseResult {
|
||||
purchase: {
|
||||
id: string;
|
||||
buyerUserId: string;
|
||||
deckId: string;
|
||||
versionId: string;
|
||||
priceCredits: number;
|
||||
authorShare: number;
|
||||
manaShare: number;
|
||||
purchasedAt: string;
|
||||
refundedAt: string | null;
|
||||
};
|
||||
payout: {
|
||||
id: string;
|
||||
authorUserId: string;
|
||||
creditsGranted: number;
|
||||
grantedAt: string;
|
||||
} | null;
|
||||
alreadyOwned: boolean;
|
||||
}
|
||||
|
||||
export interface BuyerPurchase {
|
||||
id: string;
|
||||
deckId: string;
|
||||
deckSlug: string;
|
||||
deckTitle: string;
|
||||
priceCredits: number;
|
||||
purchasedAt: string;
|
||||
refundedAt: string | null;
|
||||
versionId: string;
|
||||
versionSemver: string;
|
||||
}
|
||||
|
||||
export interface AuthorPayout {
|
||||
id: string;
|
||||
purchaseId: string;
|
||||
creditsGranted: number;
|
||||
grantedAt: string;
|
||||
deckSlug: string;
|
||||
deckTitle: string;
|
||||
priceCredits: number;
|
||||
}
|
||||
|
||||
export interface CardDiscussion {
|
||||
id: string;
|
||||
cardContentHash: string;
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@
|
|||
<nav class="flex items-center gap-4 text-xs text-neutral-400">
|
||||
<a href="/" class="hover:text-neutral-100">Meine Decks</a>
|
||||
<a href="/explore" class="hover:text-neutral-100">Entdecken</a>
|
||||
<a href="/me/purchases" class="hover:text-neutral-100">Käufe</a>
|
||||
</nav>
|
||||
<div class="flex items-center gap-3 text-xs text-neutral-500">
|
||||
{#if streak > 0}
|
||||
|
|
|
|||
|
|
@ -23,8 +23,14 @@
|
|||
let subscribed = $state(false);
|
||||
let subscribeBusy = $state(false);
|
||||
let subscribedDeckId = $state<string | null>(null);
|
||||
let hasPurchased = $state<boolean | null>(null);
|
||||
let purchaseBusy = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const isPaid = $derived(!!deck && deck.priceCredits > 0);
|
||||
const canSubscribeNow = $derived(!isPaid || hasPurchased === true);
|
||||
const isOwner = $derived(!!deck && authStore.user?.id === deck.ownerUserId);
|
||||
|
||||
$effect(() => {
|
||||
if (!slug) return;
|
||||
load();
|
||||
|
|
@ -36,6 +42,7 @@
|
|||
const r = await cardsApi.decks.bySlug(slug);
|
||||
deck = r.deck;
|
||||
version = r.latestVersion;
|
||||
hasPurchased = r.hasPurchased;
|
||||
subscribed = await isSubscribedLocally(slug);
|
||||
if (subscribed) {
|
||||
const local = await cardDeckTable
|
||||
|
|
@ -75,6 +82,21 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function buy() {
|
||||
if (!deck || purchaseBusy) return;
|
||||
if (!confirm(`Deck „${deck.title}" für ${deck.priceCredits} Credits kaufen?`)) return;
|
||||
purchaseBusy = true;
|
||||
error = null;
|
||||
try {
|
||||
await cardsApi.purchases.buy(deck.slug);
|
||||
hasPurchased = true;
|
||||
} catch (e) {
|
||||
error = e instanceof CardsApiError ? e.message : (e as Error).message;
|
||||
} finally {
|
||||
purchaseBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSubscribe() {
|
||||
if (!deck || subscribeBusy) return;
|
||||
subscribeBusy = true;
|
||||
|
|
@ -177,6 +199,14 @@
|
|||
Lernen
|
||||
</button>
|
||||
{/if}
|
||||
{:else if isPaid && !canSubscribeNow && !isOwner}
|
||||
<button
|
||||
class="rounded-lg bg-amber-500 px-4 py-2 text-sm font-medium text-amber-950 hover:bg-amber-400 disabled:opacity-50"
|
||||
onclick={buy}
|
||||
disabled={purchaseBusy || !version}
|
||||
>
|
||||
{purchaseBusy ? 'Verarbeite…' : `Kaufen für ${deck.priceCredits} 💎`}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="rounded-lg bg-indigo-500 px-4 py-2 text-sm text-white hover:bg-indigo-400 disabled:opacity-50"
|
||||
|
|
@ -186,6 +216,14 @@
|
|||
>
|
||||
{subscribeBusy ? 'Abonniere…' : 'Abonnieren'}
|
||||
</button>
|
||||
{#if isPaid && hasPurchased}
|
||||
<span
|
||||
class="rounded-full bg-emerald-500/15 px-2 py-1 text-xs text-emerald-300"
|
||||
title="Du besitzt dieses Deck"
|
||||
>
|
||||
✓ Gekauft
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else}
|
||||
<a
|
||||
|
|
|
|||
132
apps/cards/apps/web/src/routes/me/purchases/+page.svelte
Normal file
132
apps/cards/apps/web/src/routes/me/purchases/+page.svelte
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
cardsApi,
|
||||
CardsApiError,
|
||||
type BuyerPurchase,
|
||||
type AuthorPayout,
|
||||
} from '$lib/api/cards-api';
|
||||
|
||||
let purchases = $state<BuyerPurchase[]>([]);
|
||||
let payouts = $state<AuthorPayout[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const totalSpent = $derived(
|
||||
purchases.filter((p) => !p.refundedAt).reduce((acc, p) => acc + p.priceCredits, 0)
|
||||
);
|
||||
const totalEarned = $derived(payouts.reduce((acc, p) => acc + p.creditsGranted, 0));
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const [p, py] = await Promise.all([
|
||||
cardsApi.purchases.listMine(),
|
||||
cardsApi.payouts.listMine().catch(() => [] as AuthorPayout[]),
|
||||
]);
|
||||
purchases = p;
|
||||
payouts = py;
|
||||
} catch (e) {
|
||||
error = e instanceof CardsApiError ? e.message : (e as Error).message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Meine Käufe — Cards</title>
|
||||
</svelte:head>
|
||||
|
||||
<main class="mx-auto max-w-3xl px-6 py-8">
|
||||
<h1 class="mb-6 text-2xl font-semibold tracking-tight">Käufe & Auszahlungen</h1>
|
||||
|
||||
{#if error}
|
||||
<p class="mb-4 rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<section class="mb-10">
|
||||
<header class="mb-3 flex items-baseline justify-between">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-neutral-400">Käufe</h2>
|
||||
<span class="text-xs text-neutral-500">Ausgegeben: {totalSpent} 💎</span>
|
||||
</header>
|
||||
|
||||
{#if loading}
|
||||
<p class="rounded-xl border border-neutral-800 bg-neutral-900 p-4 text-sm text-neutral-500">
|
||||
Lädt…
|
||||
</p>
|
||||
{:else if purchases.length === 0}
|
||||
<p class="rounded-xl border border-neutral-800 bg-neutral-900 p-4 text-sm text-neutral-500">
|
||||
Du hast noch keine Decks gekauft.
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="space-y-2">
|
||||
{#each purchases as p (p.id)}
|
||||
<li
|
||||
class="flex items-center justify-between rounded-xl border border-neutral-800 bg-neutral-900 p-4"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<a
|
||||
href="/d/{p.deckSlug}"
|
||||
class="truncate font-medium text-neutral-100 hover:text-indigo-300"
|
||||
>
|
||||
{p.deckTitle}
|
||||
</a>
|
||||
<p class="mt-1 text-xs text-neutral-500">
|
||||
v{p.versionSemver} · {new Date(p.purchasedAt).toLocaleDateString('de-DE')}
|
||||
{#if p.refundedAt}
|
||||
<span class="ml-2 rounded bg-amber-500/15 px-1.5 py-0.5 text-amber-300"
|
||||
>Erstattet</span
|
||||
>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<span class="shrink-0 text-sm text-neutral-300">{p.priceCredits} 💎</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if payouts.length > 0 || (!loading && payouts.length === 0)}
|
||||
<section>
|
||||
<header class="mb-3 flex items-baseline justify-between">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-neutral-400">
|
||||
Author-Auszahlungen
|
||||
</h2>
|
||||
<span class="text-xs text-neutral-500">Erhalten: {totalEarned} 💎</span>
|
||||
</header>
|
||||
|
||||
{#if payouts.length === 0}
|
||||
<p class="rounded-xl border border-neutral-800 bg-neutral-900 p-4 text-sm text-neutral-500">
|
||||
Noch keine Auszahlungen — sobald jemand eines deiner kostenpflichtigen Decks kauft, landet
|
||||
die Author-Beteiligung hier.
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="space-y-2">
|
||||
{#each payouts as p (p.id)}
|
||||
<li
|
||||
class="flex items-center justify-between rounded-xl border border-neutral-800 bg-neutral-900 p-4"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<a
|
||||
href="/d/{p.deckSlug}"
|
||||
class="truncate font-medium text-neutral-100 hover:text-indigo-300"
|
||||
>
|
||||
{p.deckTitle}
|
||||
</a>
|
||||
<p class="mt-1 text-xs text-neutral-500">
|
||||
Verkauf {p.priceCredits} 💎 · gutgeschrieben {new Date(
|
||||
p.grantedAt
|
||||
).toLocaleDateString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
<span class="shrink-0 text-sm text-emerald-300">+{p.creditsGranted} 💎</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</main>
|
||||
Loading…
Add table
Add a link
Reference in a new issue