mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:21:10 +02:00
✨ feat(manacore): add gift code web UI
Add web interface for redeeming and managing gift codes: - API service for all gift operations (create, redeem, list, cancel) - Public preview page at /g/[code] for gift info before login - Protected redemption flow with riddle support - Dashboard with tabs: received gifts, created codes, create new - Gift icon added to navigation and shared-ui
This commit is contained in:
parent
acd8d02ec8
commit
a4ef703761
7 changed files with 1693 additions and 0 deletions
171
apps/manacore/apps/web/src/lib/api/gifts.ts
Normal file
171
apps/manacore/apps/web/src/lib/api/gifts.ts
Normal file
|
|
@ -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<T>(endpoint: string): Promise<T> {
|
||||
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<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
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<GiftCodeInfo> {
|
||||
return fetchPublic<GiftCodeInfo>(`/api/v1/gifts/${code.toUpperCase()}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Redeem a gift code
|
||||
*/
|
||||
async redeemGift(code: string, answer?: string): Promise<GiftRedeemResponse> {
|
||||
return fetchWithAuth<GiftRedeemResponse>(`/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<GiftListItem[]> {
|
||||
return fetchWithAuth<GiftListItem[]>('/api/v1/gifts/me/created');
|
||||
},
|
||||
|
||||
/**
|
||||
* List gifts received by the authenticated user
|
||||
*/
|
||||
async getReceivedGifts(): Promise<ReceivedGiftItem[]> {
|
||||
return fetchWithAuth<ReceivedGiftItem[]>('/api/v1/gifts/me/received');
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new gift code
|
||||
*/
|
||||
async createGift(request: CreateGiftRequest): Promise<CreateGiftResponse> {
|
||||
return fetchWithAuth<CreateGiftResponse>('/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',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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' },
|
||||
|
|
|
|||
731
apps/manacore/apps/web/src/routes/(app)/gifts/+page.svelte
Normal file
731
apps/manacore/apps/web/src/routes/(app)/gifts/+page.svelte
Normal file
|
|
@ -0,0 +1,731 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { Card, PageHeader } from '@manacore/shared-ui';
|
||||
import {
|
||||
giftsService,
|
||||
type GiftListItem,
|
||||
type ReceivedGiftItem,
|
||||
type CreateGiftRequest,
|
||||
} from '$lib/api/gifts';
|
||||
import { creditsService, type CreditBalance } from '$lib/api/credits';
|
||||
|
||||
let receivedGifts = $state<ReceivedGiftItem[]>([]);
|
||||
let createdGifts = $state<GiftListItem[]>([]);
|
||||
let balance = $state<CreditBalance | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let activeTab = $state<'received' | 'created' | 'create'>('received');
|
||||
|
||||
// Create form state
|
||||
let createCredits = $state(50);
|
||||
let createType = $state<'simple' | 'split' | 'riddle'>('simple');
|
||||
let createPortions = $state(1);
|
||||
let createMessage = $state('');
|
||||
let createRiddleQuestion = $state('');
|
||||
let createRiddleAnswer = $state('');
|
||||
let creating = $state(false);
|
||||
let createError = $state<string | null>(null);
|
||||
let createdGift = $state<{ code: string; url: string } | null>(null);
|
||||
|
||||
// Cancel state
|
||||
let cancellingId = $state<string | null>(null);
|
||||
|
||||
// Toast notification
|
||||
let toastMessage = $state<string | null>(null);
|
||||
let toastType = $state<'success' | 'error'>('success');
|
||||
|
||||
// Handle tab from URL params
|
||||
$effect(() => {
|
||||
const tab = $page.url.searchParams.get('tab');
|
||||
if (tab === 'created') activeTab = 'created';
|
||||
else if (tab === 'create') activeTab = 'create';
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
await loadData();
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const [received, created, balanceData] = await Promise.all([
|
||||
giftsService.getReceivedGifts(),
|
||||
giftsService.getCreatedGifts(),
|
||||
creditsService.getBalance(),
|
||||
]);
|
||||
receivedGifts = received;
|
||||
createdGifts = created;
|
||||
balance = balanceData;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Laden der Daten';
|
||||
console.error('Failed to load gifts data:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (createCredits < 1) {
|
||||
createError = 'Mindestens 1 Credit erforderlich';
|
||||
return;
|
||||
}
|
||||
|
||||
if (createType === 'riddle' && (!createRiddleQuestion.trim() || !createRiddleAnswer.trim())) {
|
||||
createError = 'Frage und Antwort sind für Rätsel-Geschenke erforderlich';
|
||||
return;
|
||||
}
|
||||
|
||||
creating = true;
|
||||
createError = null;
|
||||
createdGift = null;
|
||||
|
||||
try {
|
||||
const request: CreateGiftRequest = {
|
||||
credits: createCredits,
|
||||
type: createType === 'split' ? 'split' : createType,
|
||||
portions: createType === 'split' ? createPortions : 1,
|
||||
message: createMessage.trim() || undefined,
|
||||
riddleQuestion: createType === 'riddle' ? createRiddleQuestion.trim() : undefined,
|
||||
riddleAnswer: createType === 'riddle' ? createRiddleAnswer.trim() : undefined,
|
||||
};
|
||||
|
||||
const result = await giftsService.createGift(request);
|
||||
createdGift = { code: result.code, url: result.url };
|
||||
showToast('Geschenk-Code erstellt!', 'success');
|
||||
|
||||
// Reset form
|
||||
createCredits = 50;
|
||||
createType = 'simple';
|
||||
createPortions = 1;
|
||||
createMessage = '';
|
||||
createRiddleQuestion = '';
|
||||
createRiddleAnswer = '';
|
||||
|
||||
// Reload data
|
||||
await loadData();
|
||||
} catch (e) {
|
||||
createError = e instanceof Error ? e.message : 'Erstellen fehlgeschlagen';
|
||||
showToast(createError, 'error');
|
||||
console.error('Failed to create gift:', e);
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel(gift: GiftListItem) {
|
||||
if (
|
||||
!confirm(
|
||||
`Möchtest du den Code ${gift.code} wirklich stornieren? Die nicht eingelösten Credits werden erstattet.`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
cancellingId = gift.id;
|
||||
try {
|
||||
const result = await giftsService.cancelGift(gift.id);
|
||||
showToast(`${result.refundedCredits} Credits erstattet`, 'success');
|
||||
await loadData();
|
||||
} catch (e) {
|
||||
showToast(e instanceof Error ? e.message : 'Stornieren fehlgeschlagen', 'error');
|
||||
console.error('Failed to cancel gift:', e);
|
||||
} finally {
|
||||
cancellingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
showToast('In Zwischenablage kopiert', 'success');
|
||||
}
|
||||
|
||||
function showToast(message: string, type: 'success' | 'error') {
|
||||
toastMessage = message;
|
||||
toastType = type;
|
||||
setTimeout(() => {
|
||||
toastMessage = null;
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
function formatCredits(amount: number): string {
|
||||
return amount.toLocaleString('de-DE');
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function getStatusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'Aktiv';
|
||||
case 'depleted':
|
||||
return 'Eingelöst';
|
||||
case 'expired':
|
||||
return 'Abgelaufen';
|
||||
case 'cancelled':
|
||||
return 'Storniert';
|
||||
case 'refunded':
|
||||
return 'Erstattet';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'text-green-600 dark:text-green-400';
|
||||
case 'depleted':
|
||||
return 'text-blue-600 dark:text-blue-400';
|
||||
case 'expired':
|
||||
case 'cancelled':
|
||||
case 'refunded':
|
||||
return 'text-muted-foreground';
|
||||
default:
|
||||
return 'text-foreground';
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeLabel(type: string): string {
|
||||
switch (type) {
|
||||
case 'simple':
|
||||
return 'Einfach';
|
||||
case 'split':
|
||||
return 'Geteilt';
|
||||
case 'riddle':
|
||||
return 'Rätsel';
|
||||
case 'personalized':
|
||||
return 'Persönlich';
|
||||
case 'first_come':
|
||||
return 'Erste kommen';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<PageHeader title="Geschenke" description="Verschenke Credits an Freunde" size="lg" />
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
|
||||
></div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<Card>
|
||||
<div class="text-center py-8">
|
||||
<p class="text-red-500 mb-4">{error}</p>
|
||||
<button
|
||||
onclick={loadData}
|
||||
class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<!-- Quick actions -->
|
||||
<div class="mb-8 flex flex-wrap gap-4">
|
||||
<a
|
||||
href="/gifts/redeem"
|
||||
class="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<span>🎁</span>
|
||||
Code einlösen
|
||||
</a>
|
||||
<button
|
||||
onclick={() => (activeTab = 'create')}
|
||||
class="flex items-center gap-2 rounded-lg bg-surface px-4 py-2 font-medium text-foreground hover:bg-surface-hover border border-border"
|
||||
>
|
||||
<span>✨</span>
|
||||
Geschenk erstellen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-2 mb-6 border-b border-border">
|
||||
<button
|
||||
onclick={() => (activeTab = 'received')}
|
||||
class="px-4 py-2 -mb-px transition-colors {activeTab === 'received'
|
||||
? 'border-b-2 border-primary text-primary font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
>
|
||||
Erhalten ({receivedGifts.length})
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (activeTab = 'created')}
|
||||
class="px-4 py-2 -mb-px transition-colors {activeTab === 'created'
|
||||
? 'border-b-2 border-primary text-primary font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
>
|
||||
Erstellt ({createdGifts.length})
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (activeTab = 'create')}
|
||||
class="px-4 py-2 -mb-px transition-colors {activeTab === 'create'
|
||||
? 'border-b-2 border-primary text-primary font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
>
|
||||
Erstellen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
{#if activeTab === 'received'}
|
||||
<Card>
|
||||
<h3 class="text-lg font-semibold mb-4">Erhaltene Geschenke</h3>
|
||||
{#if receivedGifts.length === 0}
|
||||
<div class="text-center py-8">
|
||||
<span class="text-4xl">🎁</span>
|
||||
<p class="mt-2 text-muted-foreground">Du hast noch keine Geschenke erhalten</p>
|
||||
<a
|
||||
href="/gifts/redeem"
|
||||
class="mt-4 inline-block rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Code einlösen
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each receivedGifts as gift}
|
||||
<div
|
||||
class="flex items-center justify-between p-4 rounded-lg border border-border hover:bg-surface transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/20"
|
||||
>
|
||||
<span class="text-2xl">🎁</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-mono font-medium">{gift.code}</p>
|
||||
{#if gift.creatorName}
|
||||
<p class="text-sm text-muted-foreground">Von {gift.creatorName}</p>
|
||||
{/if}
|
||||
{#if gift.message}
|
||||
<p class="text-sm italic text-muted-foreground mt-1">"{gift.message}"</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-xl font-bold text-green-600 dark:text-green-400">
|
||||
+{formatCredits(gift.credits)}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">{formatDate(gift.redeemedAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
{:else if activeTab === 'created'}
|
||||
<Card>
|
||||
<h3 class="text-lg font-semibold mb-4">Erstellte Geschenk-Codes</h3>
|
||||
{#if createdGifts.length === 0}
|
||||
<div class="text-center py-8">
|
||||
<span class="text-4xl">✨</span>
|
||||
<p class="mt-2 text-muted-foreground">Du hast noch keine Geschenke erstellt</p>
|
||||
<button
|
||||
onclick={() => (activeTab = 'create')}
|
||||
class="mt-4 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Geschenk erstellen
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each createdGifts as gift}
|
||||
<div class="p-4 rounded-lg border border-border hover:bg-surface transition-colors">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-mono font-bold text-lg">{gift.code}</span>
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs rounded-full bg-surface {getStatusColor(
|
||||
gift.status
|
||||
)}"
|
||||
>
|
||||
{getStatusLabel(gift.status)}
|
||||
</span>
|
||||
<span class="px-2 py-0.5 text-xs rounded-full bg-surface text-muted-foreground">
|
||||
{getTypeLabel(gift.type)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={() => copyToClipboard(gift.url)}
|
||||
class="p-2 rounded hover:bg-surface-hover text-muted-foreground hover:text-foreground"
|
||||
title="Link kopieren"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
{#if gift.status === 'active'}
|
||||
<button
|
||||
onclick={() => handleCancel(gift)}
|
||||
disabled={cancellingId === gift.id}
|
||||
class="p-2 rounded hover:bg-red-100 dark:hover:bg-red-900/20 text-muted-foreground hover:text-red-600 disabled:opacity-50"
|
||||
title="Stornieren"
|
||||
>
|
||||
{#if cancellingId === gift.id}
|
||||
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
></path>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<p class="text-muted-foreground">Credits</p>
|
||||
<p class="font-medium">{formatCredits(gift.totalCredits)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Eingelöst</p>
|
||||
<p class="font-medium">{gift.claimedPortions} / {gift.totalPortions}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Erstellt</p>
|
||||
<p class="font-medium">{formatDate(gift.createdAt)}</p>
|
||||
</div>
|
||||
{#if gift.expiresAt}
|
||||
<div>
|
||||
<p class="text-muted-foreground">Gültig bis</p>
|
||||
<p class="font-medium">{formatDate(gift.expiresAt)}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if gift.message}
|
||||
<p class="mt-2 text-sm italic text-muted-foreground">"{gift.message}"</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
{:else if activeTab === 'create'}
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<!-- Create form -->
|
||||
<Card>
|
||||
<h3 class="text-lg font-semibold mb-4">Neues Geschenk erstellen</h3>
|
||||
|
||||
{#if balance}
|
||||
<div class="mb-6 rounded-lg bg-surface p-4 text-center">
|
||||
<p class="text-sm text-muted-foreground">Verfügbare Credits</p>
|
||||
<p class="text-2xl font-bold text-primary">
|
||||
{formatCredits(balance.balance + balance.freeCreditsRemaining)}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="credits" class="block text-sm font-medium text-foreground mb-2">
|
||||
Credits
|
||||
</label>
|
||||
<input
|
||||
id="credits"
|
||||
type="number"
|
||||
bind:value={createCredits}
|
||||
min="1"
|
||||
max="10000"
|
||||
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
disabled={creating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="type" class="block text-sm font-medium text-foreground mb-2"> Art </label>
|
||||
<select
|
||||
id="type"
|
||||
bind:value={createType}
|
||||
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
disabled={creating}
|
||||
>
|
||||
<option value="simple">Einfach (1 Person)</option>
|
||||
<option value="split">Geteilt (mehrere Personen)</option>
|
||||
<option value="riddle">Mit Rätsel</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if createType === 'split'}
|
||||
<div>
|
||||
<label for="portions" class="block text-sm font-medium text-foreground mb-2">
|
||||
Anzahl Portionen
|
||||
</label>
|
||||
<input
|
||||
id="portions"
|
||||
type="number"
|
||||
bind:value={createPortions}
|
||||
min="2"
|
||||
max="100"
|
||||
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
disabled={creating}
|
||||
/>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Jede Person erhält {Math.floor(createCredits / createPortions)} Credits
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if createType === 'riddle'}
|
||||
<div>
|
||||
<label for="riddle-question" class="block text-sm font-medium text-foreground mb-2">
|
||||
Rätsel-Frage
|
||||
</label>
|
||||
<input
|
||||
id="riddle-question"
|
||||
type="text"
|
||||
bind:value={createRiddleQuestion}
|
||||
placeholder="z.B. Was ist die Hauptstadt von Deutschland?"
|
||||
maxlength="200"
|
||||
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
disabled={creating}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="riddle-answer" class="block text-sm font-medium text-foreground mb-2">
|
||||
Antwort
|
||||
</label>
|
||||
<input
|
||||
id="riddle-answer"
|
||||
type="text"
|
||||
bind:value={createRiddleAnswer}
|
||||
placeholder="z.B. Berlin"
|
||||
maxlength="100"
|
||||
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
disabled={creating}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label for="message" class="block text-sm font-medium text-foreground mb-2">
|
||||
Nachricht (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
bind:value={createMessage}
|
||||
placeholder="z.B. Alles Gute zum Geburtstag!"
|
||||
maxlength="500"
|
||||
rows="3"
|
||||
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary resize-none"
|
||||
disabled={creating}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
{#if createError}
|
||||
<div class="rounded-lg bg-red-50 dark:bg-red-900/20 p-4">
|
||||
<p class="text-sm text-red-800 dark:text-red-200">{createError}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={handleCreate}
|
||||
disabled={creating || createCredits < 1}
|
||||
class="w-full rounded-lg bg-primary py-3 font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{#if creating}
|
||||
<svg class="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
Wird erstellt...
|
||||
{:else}
|
||||
✨ Geschenk-Code erstellen
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Created gift result -->
|
||||
{#if createdGift}
|
||||
<Card>
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/20"
|
||||
>
|
||||
<span class="text-4xl">🎁</span>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-foreground">Geschenk erstellt!</h3>
|
||||
<p class="mt-1 text-muted-foreground">Teile diesen Link mit dem Empfänger</p>
|
||||
|
||||
<div class="mt-6 rounded-lg bg-surface p-4">
|
||||
<p class="font-mono text-2xl font-bold text-primary">{createdGift.code}</p>
|
||||
<p class="mt-2 text-sm text-muted-foreground break-all">{createdGift.url}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-center gap-2">
|
||||
<button
|
||||
onclick={() => copyToClipboard(createdGift!.url)}
|
||||
class="rounded-lg bg-primary px-4 py-2 font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Link kopieren
|
||||
</button>
|
||||
<button
|
||||
onclick={() => copyToClipboard(createdGift!.code)}
|
||||
class="rounded-lg bg-surface px-4 py-2 font-medium text-foreground hover:bg-surface-hover border border-border"
|
||||
>
|
||||
Code kopieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={() => (createdGift = null)}
|
||||
class="mt-6 text-sm text-primary hover:underline"
|
||||
>
|
||||
Weiteres Geschenk erstellen
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<!-- Info card -->
|
||||
<Card>
|
||||
<h3 class="text-lg font-semibold mb-4">So funktioniert's</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="flex gap-3">
|
||||
<div
|
||||
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary font-bold"
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium">Credits wählen</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Bestimme, wie viele Credits du verschenken möchtest.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<div
|
||||
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary font-bold"
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium">Code erstellen</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Ein einzigartiger 6-stelliger Code wird generiert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<div
|
||||
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary font-bold"
|
||||
>
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium">Link teilen</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Sende den Link oder Code an den Empfänger.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<div
|
||||
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary font-bold"
|
||||
>
|
||||
4
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium">Einlösen</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Der Empfänger erhält die Credits auf sein Konto.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 rounded-lg bg-surface p-4">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<strong>Hinweis:</strong> 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.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Toast Notification -->
|
||||
{#if toastMessage}
|
||||
<div
|
||||
class="fixed bottom-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg animate-fade-in {toastType ===
|
||||
'success'
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-red-600 text-white'}"
|
||||
>
|
||||
{toastMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.2s ease-out;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Card, PageHeader } from '@manacore/shared-ui';
|
||||
import { giftsService, type GiftCodeInfo } from '$lib/api/gifts';
|
||||
|
||||
let code = $state('');
|
||||
let checking = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let giftInfo = $state<GiftCodeInfo | null>(null);
|
||||
|
||||
function formatCode(value: string): string {
|
||||
// Remove non-alphanumeric characters and convert to uppercase
|
||||
return value.replace(/[^A-Za-z0-9]/g, '').toUpperCase();
|
||||
}
|
||||
|
||||
function handleInput(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
code = formatCode(input.value);
|
||||
// Clear previous results when typing
|
||||
error = null;
|
||||
giftInfo = null;
|
||||
}
|
||||
|
||||
async function checkCode() {
|
||||
if (!code.trim()) {
|
||||
error = 'Bitte gib einen Code ein';
|
||||
return;
|
||||
}
|
||||
|
||||
checking = true;
|
||||
error = null;
|
||||
giftInfo = null;
|
||||
|
||||
try {
|
||||
giftInfo = await giftsService.getGiftInfo(code);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Code nicht gefunden';
|
||||
console.error('Failed to check gift code:', e);
|
||||
} finally {
|
||||
checking = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goToRedeem() {
|
||||
goto(`/gifts/redeem/${code}`);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
if (giftInfo) {
|
||||
goToRedeem();
|
||||
} else {
|
||||
checkCode();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'Aktiv';
|
||||
case 'depleted':
|
||||
return 'Aufgebraucht';
|
||||
case 'expired':
|
||||
return 'Abgelaufen';
|
||||
case 'cancelled':
|
||||
return 'Storniert';
|
||||
case 'refunded':
|
||||
return 'Erstattet';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Geschenk-Code eingeben"
|
||||
description="Gib deinen 6-stelligen Geschenk-Code ein"
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
<div class="max-w-md mx-auto">
|
||||
<Card>
|
||||
<div class="text-center mb-6">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10"
|
||||
>
|
||||
<span class="text-3xl">🎁</span>
|
||||
</div>
|
||||
<p class="text-muted-foreground">
|
||||
Geschenk-Codes bestehen aus 6 Zeichen und sind unter URLs wie
|
||||
<span class="font-mono text-primary">mana.how/g/ABC123</span> zu finden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="gift-code" class="block text-sm font-medium text-foreground mb-2">
|
||||
Geschenk-Code
|
||||
</label>
|
||||
<input
|
||||
id="gift-code"
|
||||
type="text"
|
||||
value={code}
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="z.B. MANA01"
|
||||
maxlength="10"
|
||||
class="w-full rounded-lg border border-border bg-background px-4 py-3 text-center text-2xl font-mono font-bold tracking-widest text-foreground placeholder:text-lg placeholder:font-normal placeholder:tracking-normal focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
disabled={checking}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-center">
|
||||
<p class="text-sm text-red-800 dark:text-red-200">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if giftInfo}
|
||||
<div
|
||||
class="mb-6 rounded-lg border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 p-4"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-xl">✓</span>
|
||||
<p class="font-medium text-green-800 dark:text-green-200">Gültiger Code gefunden!</p>
|
||||
</div>
|
||||
<div class="text-sm text-green-700 dark:text-green-300 space-y-1">
|
||||
<p>Credits: <span class="font-semibold">{giftInfo.creditsPerPortion}</span></p>
|
||||
<p>Status: <span class="font-semibold">{getStatusLabel(giftInfo.status)}</span></p>
|
||||
{#if giftInfo.creatorName}
|
||||
<p>Von: <span class="font-semibold">{giftInfo.creatorName}</span></p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if giftInfo && giftInfo.status === 'active'}
|
||||
<button
|
||||
onclick={goToRedeem}
|
||||
class="w-full rounded-lg bg-primary py-3 font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Jetzt einlösen
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={checkCode}
|
||||
disabled={checking || !code.trim()}
|
||||
class="w-full rounded-lg bg-primary py-3 font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{#if checking}
|
||||
<svg class="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
Code wird geprüft...
|
||||
{:else}
|
||||
Code prüfen
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<a href="/gifts" class="text-sm text-primary hover:underline"> ← Zurück zu Geschenke </a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,381 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { Card, PageHeader } from '@manacore/shared-ui';
|
||||
import { giftsService, type GiftCodeInfo } from '$lib/api/gifts';
|
||||
|
||||
let code = $derived($page.params.code);
|
||||
let giftInfo = $state<GiftCodeInfo | null>(null);
|
||||
let loading = $state(true);
|
||||
let redeeming = $state(false);
|
||||
let success = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let riddleAnswer = $state('');
|
||||
let receivedCredits = $state(0);
|
||||
let newBalance = $state(0);
|
||||
|
||||
// Toast notification
|
||||
let toastMessage = $state<string | null>(null);
|
||||
let toastType = $state<'success' | 'error'>('success');
|
||||
|
||||
onMount(async () => {
|
||||
await loadGiftInfo();
|
||||
});
|
||||
|
||||
async function loadGiftInfo() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
giftInfo = await giftsService.getGiftInfo(code);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Geschenk-Code nicht gefunden';
|
||||
console.error('Failed to load gift info:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRedeem() {
|
||||
if (!giftInfo) return;
|
||||
if (giftInfo.hasRiddle && !riddleAnswer.trim()) {
|
||||
showToast('Bitte gib die Antwort auf das Rätsel ein', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
redeeming = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const result = await giftsService.redeemGift(
|
||||
code,
|
||||
giftInfo.hasRiddle ? riddleAnswer : undefined
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
success = true;
|
||||
receivedCredits = result.credits || 0;
|
||||
newBalance = result.newBalance || 0;
|
||||
showToast(`${receivedCredits} Credits erhalten!`, 'success');
|
||||
} else {
|
||||
error = result.error || 'Einlösen fehlgeschlagen';
|
||||
showToast(error, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Einlösen fehlgeschlagen';
|
||||
showToast(error, 'error');
|
||||
console.error('Failed to redeem gift:', e);
|
||||
} finally {
|
||||
redeeming = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message: string, type: 'success' | 'error') {
|
||||
toastMessage = message;
|
||||
toastType = type;
|
||||
setTimeout(() => {
|
||||
toastMessage = null;
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function getStatusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'Aktiv';
|
||||
case 'depleted':
|
||||
return 'Aufgebraucht';
|
||||
case 'expired':
|
||||
return 'Abgelaufen';
|
||||
case 'cancelled':
|
||||
return 'Storniert';
|
||||
case 'refunded':
|
||||
return 'Erstattet';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeLabel(type: string): string {
|
||||
switch (type) {
|
||||
case 'simple':
|
||||
return 'Geschenk';
|
||||
case 'personalized':
|
||||
return 'Persönliches Geschenk';
|
||||
case 'split':
|
||||
return 'Geteiltes Geschenk';
|
||||
case 'first_come':
|
||||
return 'Erste kommen';
|
||||
case 'riddle':
|
||||
return 'Rätsel-Geschenk';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<PageHeader title="Geschenk einlösen" description="Löse deinen Geschenk-Code ein" size="lg" />
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
|
||||
></div>
|
||||
</div>
|
||||
{:else if success}
|
||||
<!-- Success state -->
|
||||
<Card>
|
||||
<div class="py-12 text-center">
|
||||
<div
|
||||
class="mx-auto mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/20 animate-bounce-once"
|
||||
>
|
||||
<span class="text-5xl">🎉</span>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-foreground">Geschenk eingelöst!</h2>
|
||||
<p class="mt-2 text-5xl font-bold text-primary">+{receivedCredits}</p>
|
||||
<p class="text-lg text-muted-foreground">Credits erhalten</p>
|
||||
<p class="mt-4 text-muted-foreground">
|
||||
Dein neuer Kontostand: <span class="font-semibold">{newBalance} Credits</span>
|
||||
</p>
|
||||
<div class="mt-8 flex justify-center gap-4">
|
||||
<a
|
||||
href="/credits"
|
||||
class="rounded-lg bg-primary px-6 py-2 font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Zu meinen Credits
|
||||
</a>
|
||||
<a
|
||||
href="/gifts"
|
||||
class="rounded-lg bg-surface px-6 py-2 font-medium text-foreground hover:bg-surface-hover"
|
||||
>
|
||||
Geschenke ansehen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{:else if error && !giftInfo}
|
||||
<!-- Gift not found error -->
|
||||
<Card>
|
||||
<div class="py-8 text-center">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/20"
|
||||
>
|
||||
<span class="text-3xl">❌</span>
|
||||
</div>
|
||||
<p class="text-red-500 mb-4">{error}</p>
|
||||
<a
|
||||
href="/gifts/redeem"
|
||||
class="inline-block rounded-lg bg-primary px-6 py-2 text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Anderen Code eingeben
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
{:else if giftInfo}
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<!-- Gift info card -->
|
||||
<Card>
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-primary/10"
|
||||
>
|
||||
<span class="text-4xl">🎁</span>
|
||||
</div>
|
||||
<p class="font-mono text-lg font-bold text-primary">{giftInfo.code}</p>
|
||||
{#if giftInfo.creatorName}
|
||||
<p class="mt-1 text-sm text-muted-foreground">Von {giftInfo.creatorName}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-sm text-muted-foreground">Du erhältst</p>
|
||||
<p class="text-4xl font-bold text-primary">{giftInfo.creditsPerPortion}</p>
|
||||
<p class="text-muted-foreground">Credits</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 space-y-3 rounded-lg bg-surface p-4">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Art</span>
|
||||
<span class="font-medium">{getTypeLabel(giftInfo.type)}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Status</span>
|
||||
<span
|
||||
class="font-medium {giftInfo.status === 'active'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-amber-600 dark:text-amber-400'}"
|
||||
>
|
||||
{getStatusLabel(giftInfo.status)}
|
||||
</span>
|
||||
</div>
|
||||
{#if giftInfo.totalPortions > 1}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Verfügbar</span>
|
||||
<span class="font-medium"
|
||||
>{giftInfo.remainingPortions} / {giftInfo.totalPortions}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#if giftInfo.expiresAt}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Gültig bis</span>
|
||||
<span class="font-medium">{formatDate(giftInfo.expiresAt)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if giftInfo.message}
|
||||
<div class="mt-6 rounded-lg border border-border p-4">
|
||||
<p class="text-sm text-muted-foreground mb-1">Nachricht:</p>
|
||||
<p class="italic text-foreground">"{giftInfo.message}"</p>
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<!-- Redemption card -->
|
||||
<Card>
|
||||
<h3 class="text-lg font-semibold mb-4">Einlösen</h3>
|
||||
|
||||
{#if giftInfo.status !== 'active'}
|
||||
<div class="rounded-lg bg-amber-50 dark:bg-amber-900/20 p-4 text-center">
|
||||
<p class="font-medium text-amber-800 dark:text-amber-200">
|
||||
{#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}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#if giftInfo.isPersonalized}
|
||||
<div class="mb-4 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xl">👤</span>
|
||||
<p class="text-sm text-blue-800 dark:text-blue-200">
|
||||
Dieses Geschenk ist für eine bestimmte Person. Nur der vorgesehene Empfänger kann
|
||||
es einlösen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if giftInfo.hasRiddle}
|
||||
<div class="mb-4 rounded-lg bg-purple-50 dark:bg-purple-900/20 p-4">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-xl">🧩</span>
|
||||
<div>
|
||||
<p class="font-medium text-purple-800 dark:text-purple-200 mb-2">Rätsel:</p>
|
||||
<p class="text-purple-900 dark:text-purple-100">{giftInfo.riddleQuestion}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="riddle-answer" class="block text-sm font-medium text-foreground mb-2">
|
||||
Deine Antwort
|
||||
</label>
|
||||
<input
|
||||
id="riddle-answer"
|
||||
type="text"
|
||||
bind:value={riddleAnswer}
|
||||
placeholder="Antwort eingeben..."
|
||||
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
disabled={redeeming}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4">
|
||||
<p class="text-sm text-red-800 dark:text-red-200">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={handleRedeem}
|
||||
disabled={redeeming || (giftInfo.hasRiddle && !riddleAnswer.trim())}
|
||||
class="w-full rounded-lg bg-primary py-3 font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{#if redeeming}
|
||||
<svg class="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
Wird eingelöst...
|
||||
{:else}
|
||||
🎁 Geschenk einlösen
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<a href="/gifts/redeem" class="text-sm text-primary hover:underline">
|
||||
Anderen Code eingeben
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Toast Notification -->
|
||||
{#if toastMessage}
|
||||
<div
|
||||
class="fixed bottom-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg animate-fade-in {toastType ===
|
||||
'success'
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-red-600 text-white'}"
|
||||
>
|
||||
{toastMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.2s ease-out;
|
||||
}
|
||||
@keyframes bounce-once {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
}
|
||||
.animate-bounce-once {
|
||||
animation: bounce-once 0.6s ease-out;
|
||||
}
|
||||
</style>
|
||||
228
apps/manacore/apps/web/src/routes/g/[code]/+page.svelte
Normal file
228
apps/manacore/apps/web/src/routes/g/[code]/+page.svelte
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { giftsService, type GiftCodeInfo } from '$lib/api/gifts';
|
||||
|
||||
let code = $derived($page.params.code);
|
||||
let giftInfo = $state<GiftCodeInfo | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
await loadGiftInfo();
|
||||
});
|
||||
|
||||
async function loadGiftInfo() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
giftInfo = await giftsService.getGiftInfo(code);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Geschenk-Code nicht gefunden';
|
||||
console.error('Failed to load gift info:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleRedeem() {
|
||||
// Redirect to the redemption page (within the authenticated area)
|
||||
goto(`/gifts/redeem/${code}`);
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function getStatusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'Aktiv';
|
||||
case 'depleted':
|
||||
return 'Aufgebraucht';
|
||||
case 'expired':
|
||||
return 'Abgelaufen';
|
||||
case 'cancelled':
|
||||
return 'Storniert';
|
||||
case 'refunded':
|
||||
return 'Erstattet';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeLabel(type: string): string {
|
||||
switch (type) {
|
||||
case 'simple':
|
||||
return 'Geschenk';
|
||||
case 'personalized':
|
||||
return 'Persönliches Geschenk';
|
||||
case 'split':
|
||||
return 'Geteiltes Geschenk';
|
||||
case 'first_come':
|
||||
return 'Erste kommen';
|
||||
case 'riddle':
|
||||
return 'Rätsel-Geschenk';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Geschenk-Code {code} | ManaCore</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-gradient-to-br from-primary/5 via-background to-primary/10">
|
||||
<div class="flex min-h-screen items-center justify-center p-4">
|
||||
<div class="w-full max-w-md">
|
||||
{#if loading}
|
||||
<div class="rounded-2xl bg-card p-8 shadow-xl">
|
||||
<div class="flex flex-col items-center justify-center py-8">
|
||||
<div
|
||||
class="h-12 w-12 animate-spin rounded-full border-4 border-primary border-t-transparent"
|
||||
></div>
|
||||
<p class="mt-4 text-muted-foreground">Geschenk wird geladen...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="rounded-2xl bg-card p-8 shadow-xl">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/20"
|
||||
>
|
||||
<span class="text-3xl">❌</span>
|
||||
</div>
|
||||
<h1 class="text-xl font-bold text-foreground">Geschenk nicht gefunden</h1>
|
||||
<p class="mt-2 text-muted-foreground">{error}</p>
|
||||
<a
|
||||
href="/gifts/redeem"
|
||||
class="mt-6 inline-block rounded-lg bg-primary px-6 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Code manuell eingeben
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else if giftInfo}
|
||||
<div class="rounded-2xl bg-card overflow-hidden shadow-xl">
|
||||
<!-- Header with gift icon -->
|
||||
<div class="bg-gradient-to-r from-primary to-primary/80 px-8 py-6 text-center text-white">
|
||||
<div
|
||||
class="mx-auto mb-3 flex h-16 w-16 items-center justify-center rounded-full bg-white/20 backdrop-blur"
|
||||
>
|
||||
<span class="text-4xl">🎁</span>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold">Du hast ein Geschenk!</h1>
|
||||
{#if giftInfo.creatorName}
|
||||
<p class="mt-1 text-sm text-white/80">Von {giftInfo.creatorName}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Gift details -->
|
||||
<div class="p-8">
|
||||
{#if giftInfo.status !== 'active'}
|
||||
<div class="mb-6 rounded-lg bg-amber-50 dark:bg-amber-900/20 p-4 text-center">
|
||||
<p class="font-medium text-amber-800 dark:text-amber-200">
|
||||
{#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}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Credits amount -->
|
||||
<div class="mb-6 text-center">
|
||||
<p class="text-sm text-muted-foreground">Du erhältst</p>
|
||||
<p class="text-5xl font-bold text-primary">{giftInfo.creditsPerPortion}</p>
|
||||
<p class="text-lg text-muted-foreground">Credits</p>
|
||||
</div>
|
||||
|
||||
<!-- Gift info -->
|
||||
<div class="mb-6 space-y-3 rounded-lg bg-surface p-4">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Art</span>
|
||||
<span class="font-medium">{getTypeLabel(giftInfo.type)}</span>
|
||||
</div>
|
||||
{#if giftInfo.totalPortions > 1}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Verfügbar</span>
|
||||
<span class="font-medium"
|
||||
>{giftInfo.remainingPortions} / {giftInfo.totalPortions}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#if giftInfo.expiresAt}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Gültig bis</span>
|
||||
<span class="font-medium">{formatDate(giftInfo.expiresAt)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Message -->
|
||||
{#if giftInfo.message}
|
||||
<div class="mb-6 rounded-lg border border-border p-4">
|
||||
<p class="text-sm text-muted-foreground mb-1">Nachricht:</p>
|
||||
<p class="italic text-foreground">"{giftInfo.message}"</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Riddle hint -->
|
||||
{#if giftInfo.hasRiddle}
|
||||
<div class="mb-6 rounded-lg bg-purple-50 dark:bg-purple-900/20 p-4 text-center">
|
||||
<span class="text-2xl">🧩</span>
|
||||
<p class="mt-1 text-sm text-purple-800 dark:text-purple-200">
|
||||
Dieses Geschenk enthält ein Rätsel
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Personalized hint -->
|
||||
{#if giftInfo.isPersonalized && giftInfo.status === 'active'}
|
||||
<div class="mb-6 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4 text-center">
|
||||
<span class="text-2xl">👤</span>
|
||||
<p class="mt-1 text-sm text-blue-800 dark:text-blue-200">
|
||||
Dieses Geschenk ist für eine bestimmte Person
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Redeem button -->
|
||||
{#if giftInfo.status === 'active'}
|
||||
<button
|
||||
onclick={handleRedeem}
|
||||
class="w-full rounded-lg bg-primary py-3 text-lg font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Jetzt einlösen
|
||||
</button>
|
||||
{:else}
|
||||
<a
|
||||
href="/gifts/redeem"
|
||||
class="block w-full rounded-lg bg-surface py-3 text-center text-lg font-semibold text-foreground transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
Anderen Code eingeben
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Code display -->
|
||||
<div class="mt-4 text-center">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Code: <span class="font-mono font-bold">{giftInfo.code}</span>
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue