feat(packages): implement package-based business model
This commit is contained in:
@@ -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 ?? [];
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
40
resources/lang/de/marketing.php
Normal file
40
resources/lang/de/marketing.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'packages' => [
|
||||
'title' => 'Unsere Packages – Wählen Sie Ihr Event-Paket',
|
||||
'hero_title' => 'Entdecken Sie unsere flexiblen Packages',
|
||||
'hero_description' => 'Von kostenlosem Einstieg bis Premium-Features: Passen Sie Ihr Event-Paket an Ihre Bedürfnisse an. Einfach, sicher und skalierbar.',
|
||||
'cta_explore' => 'Packages entdecken',
|
||||
'tab_endcustomer' => 'Endkunden',
|
||||
'tab_reseller' => 'Reseller & Agenturen',
|
||||
'section_endcustomer' => 'Packages für Endkunden (Einmalkauf pro Event)',
|
||||
'section_reseller' => 'Packages für Reseller (Jährliches Abo)',
|
||||
'free' => 'Kostenlos',
|
||||
'one_time' => 'Einmalkauf',
|
||||
'subscription' => 'Abo',
|
||||
'year' => 'Jahr',
|
||||
'max_photos' => 'Fotos',
|
||||
'max_guests' => 'Gäste',
|
||||
'gallery_days' => 'Tage Galerie',
|
||||
'max_events_year' => 'Events/Jahr',
|
||||
'buy_now' => 'Jetzt kaufen',
|
||||
'subscribe_now' => 'Jetzt abonnieren',
|
||||
'faq_title' => 'Häufige Fragen zu Packages',
|
||||
'faq_q1' => 'Was ist ein Package?',
|
||||
'faq_a1' => 'Ein Package definiert Limits und Features für Ihr Event, z.B. Anzahl Fotos und Galerie-Dauer.',
|
||||
'faq_q2' => 'Kann ich upgraden?',
|
||||
'faq_a2' => 'Ja, wählen Sie bei Event-Erstellung ein höheres Package oder upgraden Sie später.',
|
||||
'faq_q3' => 'Was passiert bei Ablauf?',
|
||||
'faq_a3' => 'Die Galerie bleibt lesbar, aber Uploads sind blockiert. Verlängern Sie einfach.',
|
||||
'faq_q4' => 'Zahlungssicher?',
|
||||
'faq_a4' => 'Ja, via Stripe oder PayPal – sicher und GDPR-konform.',
|
||||
'final_cta' => 'Bereit für Ihr nächstes Event?',
|
||||
'contact_us' => 'Kontaktieren Sie uns',
|
||||
'feature_live_slideshow' => 'Live-Slideshow',
|
||||
'feature_analytics' => 'Analytics',
|
||||
'feature_watermark' => 'Wasserzeichen',
|
||||
'feature_branding' => 'Branding',
|
||||
'feature_support' => 'Support',
|
||||
],
|
||||
];
|
||||
@@ -10,6 +10,9 @@
|
||||
<p>Wir nehmen den Schutz Ihrer persönlichen Daten sehr ernst und halten uns strikt an die Regeln der Datenschutzgesetze.</p>
|
||||
<p>Verantwortlich: Fotospiel GmbH, Musterstraße 1, 12345 Musterstadt</p>
|
||||
<p>Datenerfassung: Keine PII-Speicherung, anonyme Sessions für Gäste. E-Mails werden nur für Kontaktzwecke verarbeitet.</p>
|
||||
<h2>Zahlungen und Packages</h2>
|
||||
<p>Wir verarbeiten Zahlungen für Packages über Stripe und PayPal. Karteninformationen werden nicht gespeichert – alle Daten werden verschlüsselt übertragen. Siehe <a href="https://stripe.com/de/privacy" target="_blank">Stripe Datenschutz</a> und <a href="https://www.paypal.com/de/webapps/mpp/ua/privacy-full" target="_blank">PayPal Datenschutz</a>.</p>
|
||||
<p>Package-Daten (Limits, Features) sind anonymisiert und werden nur für die Funktionalität benötigt. Consent für Zahlungen und E-Mails wird bei Kauf eingeholt. Daten werden nach 10 Jahren gelöscht.</p>
|
||||
<p>Ihre Rechte: Auskunft, Löschung, Widerspruch. Kontaktieren Sie uns unter <a href="/kontakt">Kontakt</a>.</p>
|
||||
<p>Cookies: Nur funktionale Cookies für die PWA.</p>
|
||||
</body>
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
Vertreten durch: Max Mustermann<br>
|
||||
Kontakt: <a href="/kontakt">Kontakt</a></p>
|
||||
<p class="mb-4">Umsatzsteuer-ID: DE123456789</p>
|
||||
<h2>Monetarisierung</h2>
|
||||
<p>Wir monetarisieren über Packages (Einmalkäufe und Abos) via Stripe und PayPal. Preise exkl. MwSt. Support: support@fotospiel.de</p>
|
||||
<p>Registergericht: Amtsgericht Musterstadt</p>
|
||||
<p>Handelsregister: HRB 12345</p>
|
||||
</body>
|
||||
|
||||
@@ -43,9 +43,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<a href="/blog" class="text-gray-600 hover:text-gray-900">Blog</a>
|
||||
<a href="#pricing" class="text-gray-600 hover:text-gray-900">Pricing</a>
|
||||
<a href="/packages" class="text-gray-600 hover:text-gray-900">Packages</a>
|
||||
<a href="#contact" class="text-gray-600 hover:text-gray-900">Contact</a>
|
||||
<a href="/buy-credits/basic" class="bg-[#FFB6C1] text-white px-6 py-2 rounded-full font-semibold hover:bg-[#FF69B4] transition">Jetzt starten</a>
|
||||
<a href="/packages" class="bg-[#FFB6C1] text-white px-6 py-2 rounded-full font-semibold hover:bg-[#FF69B4] transition">Packages entdecken</a>
|
||||
</nav>
|
||||
<!-- Mobile Menu Placeholder (Hamburger) -->
|
||||
<button class="md:hidden text-gray-600">☰</button>
|
||||
@@ -58,7 +58,7 @@
|
||||
<div class="md:w-1/2 text-center md:text-left">
|
||||
<h1 class="text-4xl md:text-6xl font-bold mb-4">Fotospiel</h1>
|
||||
<p class="text-xl md:text-2xl mb-8">Sammle Gastfotos für Events mit QR-Codes. Unsere sichere PWA-Plattform für Gäste und Organisatoren – einfach, mobil und datenschutzkonform. Besser als Konkurrenz, geliebt von Tausenden.</p>
|
||||
<a href="/buy-credits/basic" class="bg-white text-[#FFB6C1] px-8 py-4 rounded-full font-semibold text-lg hover:bg-gray-100 transition">Jetzt starten – Kostenlos</a>
|
||||
<a href="/packages" class="bg-white text-[#FFB6C1] px-8 py-4 rounded-full font-semibold text-lg hover:bg-gray-100 transition">Jetzt starten – Kostenlos</a>
|
||||
</div>
|
||||
<div class="md:w-1/2">
|
||||
<img src="https://images.unsplash.com/photo-1511285560929-80b456fea0bc?w=600&h=400&fit=crop" alt="Event-Fotos mit QR" class="rounded-lg shadow-lg w-full" style="filter: drop-shadow(0 10px 8px rgba(0,0,0,0.1));">
|
||||
@@ -125,41 +125,15 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Pricing Section id="pricing" -->
|
||||
<!-- Packages Teaser Section -->
|
||||
<section id="pricing" class="py-20 px-4 bg-gray-50">
|
||||
<div class="container mx-auto">
|
||||
<h2 class="text-3xl font-bold text-center mb-12">Tarife für QR-Events</h2>
|
||||
<div class="grid md:grid-cols-3 gap-8 max-w-4xl mx-auto">
|
||||
<div class="bg-white p-8 rounded-lg text-center border-2 border-gray-200">
|
||||
<h3 class="text-2xl font-bold mb-4">Basic</h3>
|
||||
<p class="text-4xl font-bold text-[#FFB6C1] mb-4">0 €</p>
|
||||
<ul class="mb-6 space-y-2">
|
||||
<li>1 Event mit QR</li>
|
||||
<li>100 Fotos</li>
|
||||
<li>Grundfunktionen</li>
|
||||
</ul>
|
||||
<a href="/buy-credits/basic" class="bg-[#FFB6C1] text-white px-6 py-3 rounded-full font-semibold">Kostenlos starten</a>
|
||||
</div>
|
||||
<div class="bg-white p-8 rounded-lg text-center border-2 border-[#FFD700]">
|
||||
<h3 class="text-2xl font-bold mb-4">Standard</h3>
|
||||
<p class="text-4xl font-bold text-[#FFD700] mb-4">99 €</p>
|
||||
<ul class="mb-6 space-y-2">
|
||||
<li>10 Events mit QR</li>
|
||||
<li>Unbegrenzt Fotos</li>
|
||||
<li>Erweiterte Features</li>
|
||||
</ul>
|
||||
<a href="/buy-credits/standard" class="bg-[#FFD700] text-white px-6 py-3 rounded-full font-semibold">Kaufen</a>
|
||||
</div>
|
||||
<div class="bg-white p-8 rounded-lg text-center border-2 border-gray-200">
|
||||
<h3 class="text-2xl font-bold mb-4">Premium</h3>
|
||||
<p class="text-4xl font-bold text-[#87CEEB] mb-4">199 €</p>
|
||||
<ul class="mb-6 space-y-2">
|
||||
<li>50 Events mit QR</li>
|
||||
<li>Support & Custom</li>
|
||||
<li>Alle Features</li>
|
||||
</ul>
|
||||
<a href="/buy-credits/premium" class="bg-[#87CEEB] text-white px-6 py-3 rounded-full font-semibold">Kaufen</a>
|
||||
</div>
|
||||
<h2 class="text-3xl font-bold text-center mb-12">Unsere Packages</h2>
|
||||
<p class="text-center text-lg text-gray-600 mb-8">Wählen Sie das passende Paket für Ihr Event – von kostenlos bis premium.</p>
|
||||
<div class="text-center">
|
||||
<a href="/packages" class="bg-[#FFB6C1] text-white px-8 py-4 rounded-full font-semibold text-lg hover:bg-[#FF69B4] transition">
|
||||
Alle Packages ansehen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<a href="/marketing#pricing" class="text-gray-600 hover:text-gray-900">Pricing</a>
|
||||
<a href="/marketing#contact" class="text-gray-600 hover:text-gray-900">Contact</a>
|
||||
</nav>
|
||||
<a href="/buy-credits/basic" class="bg-[#FFB6C1] text-white px-6 py-2 rounded-full font-semibold">Jetzt starten</a>
|
||||
<a href="/packages" class="bg-[#FFB6C1] text-white px-6 py-2 rounded-full font-semibold">Packages wählen</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<div class="container mx-auto text-center">
|
||||
<h1 class="text-4xl md:text-6xl font-bold mb-4">Fotospiel für {{ ucfirst($type) }}</h1>
|
||||
<p class="text-xl md:text-2xl mb-8 max-w-3xl mx-auto">Sammle unvergessliche Fotos von deinen Gästen mit QR-Codes. Perfekt für {{ ucfirst($type) }} – einfach, mobil und datenschutzkonform.</p>
|
||||
<a href="/buy-credits/basic" class="bg-white text-[#FFB6C1] px-8 py-4 rounded-full font-semibold text-lg hover:bg-gray-100 transition">Event starten</a>
|
||||
<a href="/packages" class="bg-white text-[#FFB6C1] px-8 py-4 rounded-full font-semibold text-lg hover:bg-gray-100 transition">Package wählen</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
194
resources/views/marketing/packages.blade.php
Normal file
194
resources/views/marketing/packages.blade.php
Normal file
@@ -0,0 +1,194 @@
|
||||
@extends('layouts.marketing')
|
||||
|
||||
@section('title', __('marketing.packages.title'))
|
||||
|
||||
@section('content')
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<!-- Hero Section -->
|
||||
<div class="text-center mb-16">
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-4">
|
||||
{{ __('marketing.packages.hero_title') }}
|
||||
</h1>
|
||||
<p class="text-xl text-gray-600 mb-8">
|
||||
{{ __('marketing.packages.hero_description') }}
|
||||
</p>
|
||||
<a href="#endcustomer" class="bg-blue-600 hover:bg-blue-700 text-white px-8 py-3 rounded-full font-semibold transition duration-300">
|
||||
{{ __('marketing.packages.cta_explore') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Tabs for Package Types -->
|
||||
<div class="mb-12">
|
||||
<div class="border-b border-gray-200">
|
||||
<nav class="-mb-px flex space-x-8">
|
||||
<a href="#endcustomer" class="tab-link whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm border-blue-500 text-blue-600">
|
||||
{{ __('marketing.packages.tab_endcustomer') }}
|
||||
</a>
|
||||
<a href="#reseller" class="tab-link whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm border-gray-300 text-gray-500 hover:text-gray-700 hover:border-gray-300">
|
||||
{{ __('marketing.packages.tab_reseller') }}
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Endcustomer Packages -->
|
||||
<section id="endcustomer" class="mb-16">
|
||||
<h2 class="text-3xl font-bold text-gray-900 mb-8 text-center">
|
||||
{{ __('marketing.packages.section_endcustomer') }}
|
||||
</h2>
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
@foreach(\App\Models\Package::where('type', 'endcustomer')->orderBy('price')->get() as $package)
|
||||
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200 hover:shadow-lg transition duration-300">
|
||||
<div class="text-center mb-4">
|
||||
<h3 class="text-xl font-semibold text-gray-900">{{ $package->name }}</h3>
|
||||
<div class="text-3xl font-bold text-blue-600 mt-2">
|
||||
{{ $package->price }} €
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-1">{{ $package->price == 0 ? __('marketing.packages.free') : __('marketing.packages.one_time') }}</p>
|
||||
</div>
|
||||
<ul class="space-y-2 mb-6">
|
||||
@if($package->max_photos)
|
||||
<li class="flex items-center text-sm text-gray-700">
|
||||
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
{{ $package->max_photos }} {{ __('marketing.packages.max_photos') }}
|
||||
</li>
|
||||
@endif
|
||||
@if($package->max_guests)
|
||||
<li class="flex items-center text-sm text-gray-700">
|
||||
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
{{ $package->max_guests }} {{ __('marketing.packages.max_guests') }}
|
||||
</li>
|
||||
@endif
|
||||
@if($package->gallery_days)
|
||||
<li class="flex items-center text-sm text-gray-700">
|
||||
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
{{ $package->gallery_days }} {{ __('marketing.packages.gallery_days') }}
|
||||
</li>
|
||||
@endif
|
||||
@if($package->features)
|
||||
@foreach(json_decode($package->features, true) as $feature => $enabled)
|
||||
@if($enabled)
|
||||
<li class="flex items-center text-sm text-gray-700">
|
||||
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
{{ __('marketing.packages.feature_' . $feature) }}
|
||||
</li>
|
||||
@endif
|
||||
@endforeach
|
||||
@endif
|
||||
</ul>
|
||||
<a href="/packages?type=endcustomer&package_id={{ $package->id }}" class="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-md text-center font-semibold transition duration-300">
|
||||
{{ __('marketing.packages.buy_now') }}
|
||||
</a>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Reseller Packages -->
|
||||
<section id="reseller" class="mb-16">
|
||||
<h2 class="text-3xl font-bold text-gray-900 mb-8 text-center">
|
||||
{{ __('marketing.packages.section_reseller') }}
|
||||
</h2>
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
@foreach(\App\Models\Package::where('type', 'reseller')->orderBy('price')->get() as $package)
|
||||
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200 hover:shadow-lg transition duration-300">
|
||||
<div class="text-center mb-4">
|
||||
<h3 class="text-xl font-semibold text-gray-900">{{ $package->name }}</h3>
|
||||
<div class="text-3xl font-bold text-blue-600 mt-2">
|
||||
{{ $package->price }} € / {{ __('marketing.packages.year') }}
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-1">{{ __('marketing.packages.subscription') }}</p>
|
||||
</div>
|
||||
<ul class="space-y-2 mb-6">
|
||||
@if($package->max_events_per_year)
|
||||
<li class="flex items-center text-sm text-gray-700">
|
||||
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
{{ $package->max_events_per_year }} {{ __('marketing.packages.max_events_year') }}
|
||||
</li>
|
||||
@endif
|
||||
@if($package->features)
|
||||
@foreach(json_decode($package->features, true) as $feature => $enabled)
|
||||
@if($enabled)
|
||||
<li class="flex items-center text-sm text-gray-700">
|
||||
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
{{ __('marketing.packages.feature_' . $feature) }}
|
||||
</li>
|
||||
@endif
|
||||
@endforeach
|
||||
@endif
|
||||
</ul>
|
||||
<a href="/packages?type=reseller&package_id={{ $package->id }}" class="w-full bg-green-600 hover:bg-green-700 text-white py-2 px-4 rounded-md text-center font-semibold transition duration-300">
|
||||
{{ __('marketing.packages.subscribe_now') }}
|
||||
</a>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FAQ Section -->
|
||||
<section class="bg-white rounded-lg shadow-md p-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-6 text-center">
|
||||
{{ __('marketing.packages.faq_title') }}
|
||||
</h2>
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 mb-2">{{ __('marketing.packages.faq_q1') }}</h3>
|
||||
<p class="text-gray-600">{{ __('marketing.packages.faq_a1') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 mb-2">{{ __('marketing.packages.faq_q2') }}</h3>
|
||||
<p class="text-gray-600">{{ __('marketing.packages.faq_a2') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 mb-2">{{ __('marketing.packages.faq_q3') }}</h3>
|
||||
<p class="text-gray-600">{{ __('marketing.packages.faq_a3') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 mb-2">{{ __('marketing.packages.faq_q4') }}</h3>
|
||||
<p class="text-gray-600">{{ __('marketing.packages.faq_a4') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA Section -->
|
||||
<div class="text-center mt-16">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-4">
|
||||
{{ __('marketing.packages.final_cta') }}
|
||||
</h2>
|
||||
<a href="/contact" class="bg-blue-600 hover:bg-blue-700 text-white px-8 py-3 rounded-full font-semibold transition duration-300">
|
||||
{{ __('marketing.packages.contact_us') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const tabLinks = document.querySelectorAll('.tab-link');
|
||||
tabLinks.forEach(link => {
|
||||
link.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const target = this.getAttribute('href');
|
||||
document.querySelectorAll('section').forEach(section => section.style.display = 'none');
|
||||
document.querySelector(target).style.display = 'block';
|
||||
tabLinks.forEach(l => l.classList.remove('border-blue-500', 'text-blue-600'));
|
||||
this.classList.add('border-blue-500', 'text-blue-600');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
Reference in New Issue
Block a user