diff --git a/apps/manacore/apps/web/src/lib/api/gifts.ts b/apps/manacore/apps/web/src/lib/api/gifts.ts new file mode 100644 index 000000000..cfa6f8e06 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/api/gifts.ts @@ -0,0 +1,171 @@ +/** + * Gifts Service for ManaCore Web App + * Handles gift code operations + */ + +import { authStore } from '$lib/stores/auth.svelte'; + +const MANA_AUTH_URL = 'http://localhost:3001'; // TODO: Use PUBLIC_MANA_CORE_AUTH_URL from env + +// Types +export interface GiftCodeInfo { + code: string; + type: 'simple' | 'personalized' | 'split' | 'first_come' | 'riddle'; + status: 'active' | 'depleted' | 'expired' | 'cancelled' | 'refunded'; + creditsPerPortion: number; + totalPortions: number; + claimedPortions: number; + remainingPortions: number; + message?: string; + riddleQuestion?: string; + hasRiddle: boolean; + isPersonalized: boolean; + expiresAt?: string; + creatorName?: string; +} + +export interface GiftRedeemResponse { + success: boolean; + credits?: number; + message?: string; + error?: string; + newBalance?: number; +} + +export interface GiftListItem { + id: string; + code: string; + url: string; + type: string; + status: string; + totalCredits: number; + creditsPerPortion: number; + totalPortions: number; + claimedPortions: number; + message?: string; + expiresAt?: string; + createdAt: string; +} + +export interface ReceivedGiftItem { + id: string; + code: string; + credits: number; + message?: string; + creatorName?: string; + redeemedAt: string; +} + +export interface CreateGiftResponse { + id: string; + code: string; + url: string; + totalCredits: number; + creditsPerPortion: number; + totalPortions: number; + type: string; + expiresAt?: string; +} + +export interface CreateGiftRequest { + credits: number; + type?: 'simple' | 'personalized' | 'split' | 'first_come' | 'riddle'; + portions?: number; + targetEmail?: string; + targetMatrixId?: string; + riddleQuestion?: string; + riddleAnswer?: string; + message?: string; + expiresAt?: string; + sourceAppId?: string; +} + +// Helper function for public requests (no auth required) +async function fetchPublic(endpoint: string): Promise { + const response = await fetch(`${MANA_AUTH_URL}${endpoint}`, { + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Request failed' })); + throw new Error(error.message || `HTTP ${response.status}`); + } + + return response.json(); +} + +// Helper function for authenticated requests +async function fetchWithAuth(endpoint: string, options: RequestInit = {}): Promise { + const token = await authStore.getAccessToken(); + + const response = await fetch(`${MANA_AUTH_URL}${endpoint}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...options.headers, + }, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Request failed' })); + throw new Error(error.message || `HTTP ${response.status}`); + } + + return response.json(); +} + +// Gifts Service +export const giftsService = { + /** + * Get gift code info (public - no auth required) + */ + async getGiftInfo(code: string): Promise { + return fetchPublic(`/api/v1/gifts/${code.toUpperCase()}`); + }, + + /** + * Redeem a gift code + */ + async redeemGift(code: string, answer?: string): Promise { + return fetchWithAuth(`/api/v1/gifts/${code.toUpperCase()}/redeem`, { + method: 'POST', + body: JSON.stringify({ answer, sourceAppId: 'manacore-web' }), + }); + }, + + /** + * List gift codes created by the authenticated user + */ + async getCreatedGifts(): Promise { + return fetchWithAuth('/api/v1/gifts/me/created'); + }, + + /** + * List gifts received by the authenticated user + */ + async getReceivedGifts(): Promise { + return fetchWithAuth('/api/v1/gifts/me/received'); + }, + + /** + * Create a new gift code + */ + async createGift(request: CreateGiftRequest): Promise { + return fetchWithAuth('/api/v1/gifts', { + method: 'POST', + body: JSON.stringify({ ...request, sourceAppId: 'manacore-web' }), + }); + }, + + /** + * Cancel a gift code and get refund for unclaimed portions + */ + async cancelGift(id: string): Promise<{ refundedCredits: number }> { + return fetchWithAuth<{ refundedCredits: number }>(`/api/v1/gifts/${id}`, { + method: 'DELETE', + }); + }, +}; diff --git a/apps/manacore/apps/web/src/routes/(app)/+layout.svelte b/apps/manacore/apps/web/src/routes/(app)/+layout.svelte index b874620ac..db00801ba 100644 --- a/apps/manacore/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/manacore/apps/web/src/routes/(app)/+layout.svelte @@ -86,6 +86,7 @@ let baseNavItems: PillNavItem[] = [ { href: '/dashboard', label: 'Dashboard', icon: 'home' }, { href: '/credits', label: 'Credits', icon: 'creditCard' }, + { href: '/gifts', label: 'Geschenke', icon: 'gift' }, { href: '/api-keys', label: 'API Keys', icon: 'key' }, { href: '/feedback', label: 'Feedback', icon: 'chat' }, { href: '/profile', label: 'Profil', icon: 'user' }, diff --git a/apps/manacore/apps/web/src/routes/(app)/gifts/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/gifts/+page.svelte new file mode 100644 index 000000000..f92fa9427 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/gifts/+page.svelte @@ -0,0 +1,731 @@ + + +
+ + + {#if loading} +
+
+
+ {:else if error} + +
+

{error}

+ +
+
+ {:else} + +
+ + 🎁 + Code einlösen + + +
+ + +
+ + + +
+ + + {#if activeTab === 'received'} + +

Erhaltene Geschenke

+ {#if receivedGifts.length === 0} +
+ 🎁 +

Du hast noch keine Geschenke erhalten

+ + Code einlösen + +
+ {:else} +
+ {#each receivedGifts as gift} +
+
+
+ 🎁 +
+
+

{gift.code}

+ {#if gift.creatorName} +

Von {gift.creatorName}

+ {/if} + {#if gift.message} +

"{gift.message}"

+ {/if} +
+
+
+

+ +{formatCredits(gift.credits)} +

+

{formatDate(gift.redeemedAt)}

+
+
+ {/each} +
+ {/if} +
+ {:else if activeTab === 'created'} + +

Erstellte Geschenk-Codes

+ {#if createdGifts.length === 0} +
+ +

Du hast noch keine Geschenke erstellt

+ +
+ {:else} +
+ {#each createdGifts as gift} +
+
+
+ {gift.code} + + {getStatusLabel(gift.status)} + + + {getTypeLabel(gift.type)} + +
+
+ + {#if gift.status === 'active'} + + {/if} +
+
+
+
+

Credits

+

{formatCredits(gift.totalCredits)}

+
+
+

Eingelöst

+

{gift.claimedPortions} / {gift.totalPortions}

+
+
+

Erstellt

+

{formatDate(gift.createdAt)}

+
+ {#if gift.expiresAt} +
+

Gültig bis

+

{formatDate(gift.expiresAt)}

+
+ {/if} +
+ {#if gift.message} +

"{gift.message}"

+ {/if} +
+ {/each} +
+ {/if} +
+ {:else if activeTab === 'create'} +
+ + +

Neues Geschenk erstellen

+ + {#if balance} +
+

Verfügbare Credits

+

+ {formatCredits(balance.balance + balance.freeCreditsRemaining)} +

+
+ {/if} + +
+
+ + +
+ +
+ + +
+ + {#if createType === 'split'} +
+ + +

+ Jede Person erhält {Math.floor(createCredits / createPortions)} Credits +

+
+ {/if} + + {#if createType === 'riddle'} +
+ + +
+
+ + +
+ {/if} + +
+ + +
+ + {#if createError} +
+

{createError}

+
+ {/if} + + +
+
+ + + {#if createdGift} + +
+
+ 🎁 +
+

Geschenk erstellt!

+

Teile diesen Link mit dem Empfänger

+ +
+

{createdGift.code}

+

{createdGift.url}

+
+ +
+ + +
+ + +
+
+ {:else} + + +

So funktioniert's

+
+
+
+ 1 +
+
+

Credits wählen

+

+ Bestimme, wie viele Credits du verschenken möchtest. +

+
+
+
+
+ 2 +
+
+

Code erstellen

+

+ Ein einzigartiger 6-stelliger Code wird generiert. +

+
+
+
+
+ 3 +
+
+

Link teilen

+

+ Sende den Link oder Code an den Empfänger. +

+
+
+
+
+ 4 +
+
+

Einlösen

+

+ Der Empfänger erhält die Credits auf sein Konto. +

+
+
+
+ +
+

+ Hinweis: Die Credits werden beim Erstellen von deinem Konto abgezogen. + Falls der Code nicht eingelöst wird, kannst du ihn stornieren und die Credits zurückerhalten. +

+
+
+ {/if} +
+ {/if} + {/if} +
+ + +{#if toastMessage} +
+ {toastMessage} +
+{/if} + + diff --git a/apps/manacore/apps/web/src/routes/(app)/gifts/redeem/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/gifts/redeem/+page.svelte new file mode 100644 index 000000000..a2c2aa63e --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/gifts/redeem/+page.svelte @@ -0,0 +1,179 @@ + + +
+ + +
+ +
+
+ 🎁 +
+

+ Geschenk-Codes bestehen aus 6 Zeichen und sind unter URLs wie + mana.how/g/ABC123 zu finden. +

+
+ +
+ + +
+ + {#if error} +
+

{error}

+
+ {/if} + + {#if giftInfo} +
+
+ +

Gültiger Code gefunden!

+
+
+

Credits: {giftInfo.creditsPerPortion}

+

Status: {getStatusLabel(giftInfo.status)}

+ {#if giftInfo.creatorName} +

Von: {giftInfo.creatorName}

+ {/if} +
+
+ {/if} + + {#if giftInfo && giftInfo.status === 'active'} + + {:else} + + {/if} +
+ + +
+
diff --git a/apps/manacore/apps/web/src/routes/(app)/gifts/redeem/[code]/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/gifts/redeem/[code]/+page.svelte new file mode 100644 index 000000000..e577375cf --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/gifts/redeem/[code]/+page.svelte @@ -0,0 +1,381 @@ + + +
+ + + {#if loading} +
+
+
+ {:else if success} + + +
+
+ 🎉 +
+

Geschenk eingelöst!

+

+{receivedCredits}

+

Credits erhalten

+

+ Dein neuer Kontostand: {newBalance} Credits +

+ +
+
+ {:else if error && !giftInfo} + + +
+
+ +
+

{error}

+ + Anderen Code eingeben + +
+
+ {:else if giftInfo} +
+ + +
+
+ 🎁 +
+

{giftInfo.code}

+ {#if giftInfo.creatorName} +

Von {giftInfo.creatorName}

+ {/if} +
+ +
+

Du erhältst

+

{giftInfo.creditsPerPortion}

+

Credits

+
+ +
+
+ Art + {getTypeLabel(giftInfo.type)} +
+
+ Status + + {getStatusLabel(giftInfo.status)} + +
+ {#if giftInfo.totalPortions > 1} +
+ Verfügbar + {giftInfo.remainingPortions} / {giftInfo.totalPortions} +
+ {/if} + {#if giftInfo.expiresAt} +
+ Gültig bis + {formatDate(giftInfo.expiresAt)} +
+ {/if} +
+ + {#if giftInfo.message} +
+

Nachricht:

+

"{giftInfo.message}"

+
+ {/if} +
+ + + +

Einlösen

+ + {#if giftInfo.status !== 'active'} +
+

+ {#if giftInfo.status === 'depleted'} + Dieses Geschenk wurde bereits vollständig eingelöst + {:else if giftInfo.status === 'expired'} + Dieses Geschenk ist abgelaufen + {:else} + Dieses Geschenk kann nicht eingelöst werden + {/if} +

+
+ {:else} + {#if giftInfo.isPersonalized} +
+
+ 👤 +

+ Dieses Geschenk ist für eine bestimmte Person. Nur der vorgesehene Empfänger kann + es einlösen. +

+
+
+ {/if} + + {#if giftInfo.hasRiddle} +
+
+ 🧩 +
+

Rätsel:

+

{giftInfo.riddleQuestion}

+
+
+
+ +
+ + +
+ {/if} + + {#if error} +
+

{error}

+
+ {/if} + + + {/if} + + +
+
+ {/if} +
+ + +{#if toastMessage} +
+ {toastMessage} +
+{/if} + + diff --git a/apps/manacore/apps/web/src/routes/g/[code]/+page.svelte b/apps/manacore/apps/web/src/routes/g/[code]/+page.svelte new file mode 100644 index 000000000..41cd465bc --- /dev/null +++ b/apps/manacore/apps/web/src/routes/g/[code]/+page.svelte @@ -0,0 +1,228 @@ + + + + Geschenk-Code {code} | ManaCore + + +
+
+
+ {#if loading} +
+
+
+

Geschenk wird geladen...

+
+
+ {:else if error} +
+
+
+ +
+

Geschenk nicht gefunden

+

{error}

+ + Code manuell eingeben + +
+
+ {:else if giftInfo} +
+ +
+
+ 🎁 +
+

Du hast ein Geschenk!

+ {#if giftInfo.creatorName} +

Von {giftInfo.creatorName}

+ {/if} +
+ + +
+ {#if giftInfo.status !== 'active'} +
+

+ {#if giftInfo.status === 'depleted'} + Dieses Geschenk wurde bereits vollständig eingelöst + {:else if giftInfo.status === 'expired'} + Dieses Geschenk ist abgelaufen + {:else} + Status: {getStatusLabel(giftInfo.status)} + {/if} +

+
+ {/if} + + +
+

Du erhältst

+

{giftInfo.creditsPerPortion}

+

Credits

+
+ + +
+
+ Art + {getTypeLabel(giftInfo.type)} +
+ {#if giftInfo.totalPortions > 1} +
+ Verfügbar + {giftInfo.remainingPortions} / {giftInfo.totalPortions} +
+ {/if} + {#if giftInfo.expiresAt} +
+ Gültig bis + {formatDate(giftInfo.expiresAt)} +
+ {/if} +
+ + + {#if giftInfo.message} +
+

Nachricht:

+

"{giftInfo.message}"

+
+ {/if} + + + {#if giftInfo.hasRiddle} +
+ 🧩 +

+ Dieses Geschenk enthält ein Rätsel +

+
+ {/if} + + + {#if giftInfo.isPersonalized && giftInfo.status === 'active'} +
+ 👤 +

+ Dieses Geschenk ist für eine bestimmte Person +

+
+ {/if} + + + {#if giftInfo.status === 'active'} + + {:else} + + Anderen Code eingeben + + {/if} +
+
+ + +
+

+ Code: {giftInfo.code} +

+
+ {/if} +
+
+
diff --git a/packages/shared-ui/src/navigation/PillNavigation.svelte b/packages/shared-ui/src/navigation/PillNavigation.svelte index d3be41770..25701f30b 100644 --- a/packages/shared-ui/src/navigation/PillNavigation.svelte +++ b/packages/shared-ui/src/navigation/PillNavigation.svelte @@ -59,6 +59,7 @@ Robot, Key, Shield, + Gift, } from '@manacore/shared-icons'; // Map icon names to Phosphor components @@ -113,6 +114,7 @@ robot: Robot, key: Key, shield: Shield, + gift: Gift, }; // Convert app items to dropdown items (will be computed as derived)