feat(packages): implement package-based business model

This commit is contained in:
Codex Agent
2025-09-26 22:13:56 +02:00
parent 6fc36ebaf4
commit 0a643c3e4d
54 changed files with 3301 additions and 282 deletions

View File

@@ -178,3 +178,19 @@ export async function createInviteLink(slug: string): Promise<{ link: string; to
const response = await authorizedFetch(`${eventEndpoint(slug)}/invites`, { method: 'POST' });
return jsonOrThrow<{ link: string; token: string }>(response, 'Failed to create invite');
}
export type Package = {
id: number;
name: string;
price: number;
max_photos: number | null;
max_guests: number | null;
gallery_days: number | null;
features: Record<string, boolean>;
};
export async function getPackages(type: 'endcustomer' | 'reseller' = 'endcustomer'): Promise<Package[]> {
const response = await authorizedFetch(`/api/v1/packages?type=${type}`);
const data = await jsonOrThrow<{ data: Package[] }>(response, 'Failed to load packages');
return data.data ?? [];
}

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react';
import { ArrowLeft, Loader2, Save, Sparkles, Package as PackageIcon } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
@@ -8,15 +9,18 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { AdminLayout } from '../components/AdminLayout';
import { createEvent, getEvent, updateEvent } from '../api';
import { createEvent, getEvent, updateEvent, getPackages } from '../api';
import { isAuthError } from '../auth/tokens';
interface EventFormState {
name: string;
slug: string;
date: string;
package_id: number;
isPublished: boolean;
}
@@ -30,6 +34,7 @@ export default function EventFormPage() {
name: '',
slug: '',
date: '',
package_id: 1, // Default Free package
isPublished: false,
});
const [autoSlug, setAutoSlug] = React.useState(true);
@@ -38,6 +43,11 @@ export default function EventFormPage() {
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const { data: packages, isLoading: packagesLoading } = useQuery({
queryKey: ['packages', 'endcustomer'],
queryFn: () => getPackages('endcustomer'),
});
React.useEffect(() => {
let cancelled = false;
if (!isEdit || !slugParam) {
@@ -109,6 +119,7 @@ export default function EventFormPage() {
const payload = {
name: trimmedName,
slug: trimmedSlug,
package_id: form.package_id,
date: form.date || undefined,
status: form.isPublished ? 'published' : 'draft',
};
@@ -199,6 +210,50 @@ export default function EventFormPage() {
onChange={(e) => setForm((prev) => ({ ...prev, date: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="package_id">Package</Label>
<Select value={form.package_id.toString()} onValueChange={(value) => setForm((prev) => ({ ...prev, package_id: parseInt(value) }))}>
<SelectTrigger>
<SelectValue placeholder="Wählen Sie ein Package" />
</SelectTrigger>
<SelectContent>
{packagesLoading ? (
<SelectItem value="">Laden...</SelectItem>
) : (
packages?.map((pkg) => (
<SelectItem key={pkg.id} value={pkg.id.toString()}>
{pkg.name} - {pkg.price} ({pkg.max_photos} Fotos)
</SelectItem>
))
)}
</SelectContent>
</Select>
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm">Package-Details</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Package auswählen</DialogTitle>
<DialogDescription>Wählen Sie das Package für Ihr Event. Höhere Packages bieten mehr Limits und Features.</DialogDescription>
</DialogHeader>
<div className="space-y-2">
{packages?.map((pkg) => (
<div key={pkg.id} className="p-4 border rounded">
<h3 className="font-semibold">{pkg.name}</h3>
<p>{pkg.price} </p>
<ul className="text-sm">
<li>Max Fotos: {pkg.max_photos}</li>
<li>Max Gäste: {pkg.max_guests}</li>
<li>Galerie: {pkg.gallery_days} Tage</li>
<li>Features: {Object.keys(pkg.features).filter(k => pkg.features[k]).join(', ')}</li>
</ul>
</div>
))}
</div>
</DialogContent>
</Dialog>
</div>
</div>
<div className="flex items-start gap-3 rounded-xl bg-pink-50/60 p-4">

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { ArrowRight, CalendarDays, Plus, Settings, Sparkles } from 'lucide-react';
import { ArrowRight, CalendarDays, Plus, Settings, Sparkles, Package as PackageIcon } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
@@ -8,7 +9,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { AdminLayout } from '../components/AdminLayout';
import { getEvents, TenantEvent } from '../api';
import { getEvents, TenantEvent, getPackages } from '../api';
import { isAuthError } from '../auth/tokens';
export default function EventsPage() {
@@ -17,6 +18,11 @@ export default function EventsPage() {
const [error, setError] = React.useState<string | null>(null);
const navigate = useNavigate();
const { data: tenantPackages } = useQuery({
queryKey: ['tenant-packages'],
queryFn: () => getPackages('reseller'), // or separate endpoint
});
React.useEffect(() => {
(async () => {
try {
@@ -53,6 +59,33 @@ export default function EventsPage() {
subtitle="Plane Momente, die in Erinnerung bleiben. Hier verwaltest du alles rund um deine Veranstaltungen."
actions={actions}
>
{tenantPackages && tenantPackages.length > 0 && (
<Card className="mb-6 border-0 bg-white/80 shadow-xl shadow-pink-100/60">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<PackageIcon className="h-5 w-5 text-pink-500" />
Aktuelles Package
</CardTitle>
<CardDescription>
Ihr aktuelles Reseller-Package und verbleibende Limits.
</CardDescription>
</CardHeader>
<CardContent className="grid md:grid-cols-3 gap-4">
<div className="text-center">
<h3 className="font-semibold">Aktives Package</h3>
<p className="text-lg font-bold">{tenantPackages.find(p => p.active)?.package?.name || 'Kein aktives Package'}</p>
</div>
<div className="text-center">
<h3 className="font-semibold">Verbleibende Events</h3>
<p className="text-lg font-bold">{tenantPackages.find(p => p.active)?.remaining_events || 0}</p>
</div>
<div className="text-center">
<h3 className="font-semibold">Ablauf</h3>
<p className="text-lg font-bold">{tenantPackages.find(p => p.active)?.expires_at || 'Kein Package'}</p>
</div>
</CardContent>
</Card>
)}
{error && (
<Alert variant="destructive">
<AlertTitle>Fehler beim Laden</AlertTitle>

View File

@@ -2,13 +2,14 @@ import React, { Dispatch, SetStateAction, useEffect, useState } from 'react';
import { Page } from './_util';
import { useParams, useSearchParams } from 'react-router-dom';
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
import { Card, CardContent } from '@/components/ui/card';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import FiltersBar, { type GalleryFilter } from '../components/FiltersBar';
import { Heart } from 'lucide-react';
import { Heart, Users, Image as ImageIcon, Camera, Package as PackageIcon } from 'lucide-react';
import { likePhoto } from '../services/photosApi';
import PhotoLightbox from './PhotoLightbox';
import { fetchEvent, getEventPackage, fetchStats, type EventData, type EventPackage, type EventStats } from '../services/eventApi';
export default function GalleryPage() {
const { slug } = useParams();
@@ -17,6 +18,11 @@ export default function GalleryPage() {
const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState<number | null>(null);
const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false);
const [event, setEvent] = useState<EventData | null>(null);
const [eventPackage, setEventPackage] = useState<EventPackage | null>(null);
const [stats, setStats] = useState<EventStats | null>(null);
const [eventLoading, setEventLoading] = useState(true);
const [searchParams] = useSearchParams();
const photoIdParam = searchParams.get('photoId');
// Auto-open lightbox if photoId in query params
@@ -30,6 +36,31 @@ export default function GalleryPage() {
}
}, [photos, photoIdParam, currentPhotoIndex, hasOpenedPhoto]);
// Load event and package info
useEffect(() => {
if (!slug) return;
const loadEventData = async () => {
try {
setEventLoading(true);
const [eventData, packageData, statsData] = await Promise.all([
fetchEvent(slug),
getEventPackage(slug),
fetchStats(slug),
]);
setEvent(eventData);
setEventPackage(packageData);
setStats(statsData);
} catch (err) {
console.error('Failed to load event data', err);
} finally {
setEventLoading(false);
}
};
loadEventData();
}, [slug]);
const myPhotoIds = React.useMemo(() => {
try {
const raw = localStorage.getItem('my-photo-ids');
@@ -68,19 +99,68 @@ export default function GalleryPage() {
}
}
if (eventLoading) {
return <Page title="Galerie"><p>Lade Event-Info...</p></Page>;
}
return (
<Page title="Galerie">
<Card className="mx-4 mb-4">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ImageIcon className="h-6 w-6" />
Galerie: {event?.name || 'Event'}
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center">
<Users className="h-8 w-8 mx-auto mb-2 text-blue-500" />
<p className="font-semibold">Online Gäste</p>
<p className="text-2xl">{stats?.onlineGuests || 0}</p>
</div>
<div className="text-center">
<Heart className="h-8 w-8 mx-auto mb-2 text-red-500" />
<p className="font-semibold">Gesamt Likes</p>
<p className="text-2xl">{photos.reduce((sum, p) => sum + ((p as any).likes_count || 0), 0)}</p>
</div>
<div className="text-center">
<Camera className="h-8 w-8 mx-auto mb-2 text-green-500" />
<p className="font-semibold">Gesamt Fotos</p>
<p className="text-2xl">{photos.length}</p>
</div>
{eventPackage && (
<div className="text-center">
<PackageIcon className="h-8 w-8 mx-auto mb-2 text-purple-500" />
<p className="font-semibold">Package</p>
<p className="text-sm">{eventPackage.package.name}</p>
<div className="w-full bg-gray-200 rounded-full h-2 mt-1">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${(eventPackage.used_photos / eventPackage.package.max_photos) * 100}%` }}
></div>
</div>
<p className="text-xs text-gray-600 mt-1">
{eventPackage.used_photos} / {eventPackage.package.max_photos} Fotos
</p>
{new Date(eventPackage.expires_at) < new Date() && (
<p className="text-red-600 text-xs mt-1">Abgelaufen: {new Date(eventPackage.expires_at).toLocaleDateString()}</p>
)}
</div>
)}
</CardContent>
</Card>
<FiltersBar value={filter} onChange={setFilter} />
{newCount > 0 && (
<Alert className="mb-3">
<Alert className="mb-3 mx-4">
<AlertDescription>
{newCount} neue Fotos verfügbar.{' '}
<Button variant="link" className="px-1" onClick={acknowledgeNew}>Aktualisieren</Button>
</AlertDescription>
</Alert>
)}
{loading && <p>Lade</p>}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{loading && <p className="mx-4">Lade</p>}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 px-4">
{list.map((p: any) => {
// Debug: Log image URLs
const imgSrc = p.thumbnail_path || p.file_path;

View File

@@ -21,6 +21,7 @@ import {
Zap,
ZapOff,
} from 'lucide-react';
import { getEventPackage, type EventPackage } from '../services/eventApi';
interface Task {
id: number;
@@ -85,6 +86,9 @@ export default function UploadPage() {
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadError, setUploadError] = useState<string | null>(null);
const [eventPackage, setEventPackage] = useState<EventPackage | null>(null);
const [canUpload, setCanUpload] = useState(true);
const [showPrimer, setShowPrimer] = useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return window.localStorage.getItem(primerStorageKey) !== '1';
@@ -201,6 +205,30 @@ export default function UploadPage() {
};
}, [slug, taskId, emotionSlug]);
// Check upload limits
useEffect(() => {
if (!slug || !task) return;
const checkLimits = async () => {
try {
const pkg = await getEventPackage(slug);
setEventPackage(pkg);
if (pkg && pkg.used_photos >= pkg.package.max_photos) {
setCanUpload(false);
setUploadError('Upload-Limit erreicht. Kontaktieren Sie den Organisator für ein Upgrade.');
} else {
setCanUpload(true);
}
} catch (err) {
console.error('Failed to check package limits', err);
setCanUpload(false);
setUploadError('Fehler beim Prüfen des Limits. Upload deaktiviert.');
}
};
checkLimits();
}, [slug, task]);
const stopStream = useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
@@ -428,7 +456,7 @@ export default function UploadPage() {
);
const handleUsePhoto = useCallback(async () => {
if (!slug || !reviewPhoto || !task) return;
if (!slug || !reviewPhoto || !task || !canUpload) return;
setMode('uploading');
setUploadProgress(5);
setUploadError(null);
@@ -459,9 +487,10 @@ export default function UploadPage() {
}
setStatusMessage('');
}
}, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, slug, stopStream, task]);
}, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, slug, stopStream, task, canUpload]);
const handleGalleryPick = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
if (!canUpload) return;
const file = event.target.files?.[0];
if (!file) return;
setUploadError(null);
@@ -474,7 +503,7 @@ export default function UploadPage() {
setUploadError('Auswahl fehlgeschlagen. Bitte versuche es erneut.');
};
reader.readAsDataURL(file);
}, []);
}, [canUpload]);
const difficultyBadgeClass = useMemo(() => {
if (!task) return 'text-white';
@@ -491,6 +520,8 @@ export default function UploadPage() {
const isCameraActive = permissionState === 'granted' && mode !== 'uploading';
const showTaskOverlay = task && mode !== 'uploading';
const isUploadDisabled = !canUpload || !task;
useEffect(() => () => {
resetCountdownTimer();
if (uploadProgressTimerRef.current) {
@@ -527,6 +558,24 @@ export default function UploadPage() {
);
}
if (!canUpload) {
return (
<div className="pb-16">
<Header slug={slug} title="Kamera" />
<main className="px-4 py-6">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Upload-Limit erreicht ({eventPackage?.used_photos || 0} / {eventPackage?.package.max_photos || 0} Fotos).
Kontaktieren Sie den Organisator für ein Package-Upgrade.
</AlertDescription>
</Alert>
</main>
<BottomNav />
</div>
);
}
const renderPrimer = () => (
showPrimer && (
<div className="mx-4 mt-3 rounded-xl border border-pink-200 bg-white/90 p-4 text-sm text-pink-900 shadow">

View File

@@ -14,6 +14,19 @@ export interface EventData {
};
}
export interface PackageData {
id: number;
name: string;
max_photos: number;
}
export interface EventPackage {
id: number;
used_photos: number;
expires_at: string;
package: PackageData;
}
export interface EventStats {
onlineGuests: number;
tasksSolved: number;
@@ -39,4 +52,13 @@ export async function fetchStats(slug: string): Promise<EventStats> {
tasksSolved: json.tasksSolved ?? 0,
latestPhotoAt: json.latestPhotoAt ?? null,
};
}
export async function getEventPackage(slug: string): Promise<EventPackage | null> {
const res = await fetch(`/api/v1/events/${slug}/package`);
if (!res.ok) {
if (res.status === 404) return null;
throw new Error('Failed to load event package');
}
return await res.json();
}