feat: add plan template picker for membership plan creation
continuous-integration/drone/push Build is passing Details

- Add PlanTemplate type and listPlanTemplates API function
- Create TemplateCard and TemplatePicker components
- Modify PlanFormModal to accept initialValues from template
- Update MembershipPlansComponent to show template picker first
- Add missing translations (Venue Management, Club Management, etc.)
master
Guillermo Pages 3 weeks ago
parent 826c42442e
commit 9f059bcbfe

@ -836,5 +836,36 @@
"approval(s)": "Genehmigung(en)",
"All approved": "Alle genehmigt",
"Slot": "Zeitfenster",
"© 2024 Playchoo Manager. Venue administration portal.": "© 2024 Playchoo Manager. Verwaltungsportal für Veranstaltungsorte."
"© 2024 Playchoo Manager. Venue administration portal.": "© 2024 Playchoo Manager. Verwaltungsportal für Veranstaltungsorte.",
"Club Management": "Clubverwaltung",
"Venue Management": "Anlagenverwaltung",
"View and manage your venue locations": "Ihre Veranstaltungsorte anzeigen und verwalten",
"Clubs": "Clubs",
"Coming soon": "Demnächst",
"Schedules": "Zeitpläne",
"Bookings": "Buchungen",
"Loading clubs...": "Clubs werden geladen...",
"Error Loading Clubs": "Fehler beim Laden der Clubs",
"Error code": "Fehlercode",
"No Clubs Assigned": "Keine Clubs zugewiesen",
"You are not currently assigned as an administrator for any clubs. Contact your organization to request access.": "Sie sind derzeit keinem Club als Administrator zugewiesen. Kontaktieren Sie Ihre Organisation, um Zugang zu beantragen.",
"Courts": "Plätze",
"Loading club details...": "Club-Details werden geladen...",
"Access Denied": "Zugriff verweigert",
"Back to clubs": "Zurück zu Clubs",
"Error Loading Club": "Fehler beim Laden des Clubs",
"Provider Information": "Anbieterinformationen",
"Type": "Typ",
"Manages Slot Storage": "Verwaltet Slot-Speicher",
"Supports Payment Verification": "Unterstützt Zahlungsverifizierung",
"No courts configured": "Keine Plätze konfiguriert",
"Slot Definitions": "Slot-Definitionen",
"No slot definitions configured": "Keine Slot-Definitionen konfiguriert",
"Slot definition": "Slot-Definition",
"Upcoming Slots": "Kommende Slots",
"No upcoming slots available": "Keine kommenden Slots verfügbar",
"Checking authentication...": "Authentifizierung wird überprüft...",
"Redirecting to login...": "Weiterleitung zur Anmeldung...",
"Please log in to access the venue management portal.": "Bitte melden Sie sich an, um auf das Verwaltungsportal zuzugreifen.",
"If you are a venue administrator and do not have access, please contact support.": "Wenn Sie ein Anlagen-Administrator sind und keinen Zugang haben, kontaktieren Sie bitte den Support."
}

@ -838,6 +838,7 @@
"approval(s)": "approval(s)",
"All approved": "All approved",
"Club Management": "Club Management",
"Venue Management": "Venue Management",
"View and manage your venue locations": "View and manage your venue locations",
"Clubs": "Clubs",
"Coming soon": "Coming soon",

@ -832,5 +832,36 @@
"approval(s)": "approbation(s)",
"All approved": "Toutes approuvées",
"Slot": "Créneau",
"© 2024 Playchoo Manager. Venue administration portal.": "© 2024 Playchoo Manager. Portail d'administration de lieu."
"© 2024 Playchoo Manager. Venue administration portal.": "© 2024 Playchoo Manager. Portail d'administration de lieu.",
"Club Management": "Gestion de club",
"Venue Management": "Gestion de lieu",
"View and manage your venue locations": "Voir et gérer vos sites",
"Clubs": "Clubs",
"Coming soon": "Bientôt disponible",
"Schedules": "Horaires",
"Bookings": "Réservations",
"Loading clubs...": "Chargement des clubs...",
"Error Loading Clubs": "Erreur de chargement des clubs",
"Error code": "Code d'erreur",
"No Clubs Assigned": "Aucun club assigné",
"You are not currently assigned as an administrator for any clubs. Contact your organization to request access.": "Vous n'êtes actuellement assigné comme administrateur d'aucun club. Contactez votre organisation pour demander l'accès.",
"Courts": "Terrains",
"Loading club details...": "Chargement des détails du club...",
"Access Denied": "Accès refusé",
"Back to clubs": "Retour aux clubs",
"Error Loading Club": "Erreur de chargement du club",
"Provider Information": "Informations du fournisseur",
"Type": "Type",
"Manages Slot Storage": "Gère le stockage des créneaux",
"Supports Payment Verification": "Prend en charge la vérification des paiements",
"No courts configured": "Aucun terrain configuré",
"Slot Definitions": "Définitions de créneaux",
"No slot definitions configured": "Aucune définition de créneau configurée",
"Slot definition": "Définition de créneau",
"Upcoming Slots": "Créneaux à venir",
"No upcoming slots available": "Aucun créneau à venir disponible",
"Checking authentication...": "Vérification de l'authentification...",
"Redirecting to login...": "Redirection vers la connexion...",
"Please log in to access the venue management portal.": "Veuillez vous connecter pour accéder au portail de gestion.",
"If you are a venue administrator and do not have access, please contact support.": "Si vous êtes administrateur de lieu et n'avez pas accès, veuillez contacter le support."
}

@ -2,10 +2,11 @@
import { useState, useEffect } from 'react';
import { listPlans, deletePlan } from '@/src/lib/api/facility-admin';
import type { MembershipPlan } from '@/src/types/facility-admin';
import type { MembershipPlan, PlanTemplate } from '@/src/types/facility-admin';
import PlanCard from '@/src/components/plans/PlanCard';
import PlanFormModal from '@/src/components/plans/PlanFormModal';
import PlanListSkeleton from '@/src/components/plans/PlanListSkeleton';
import TemplatePicker from '@/src/components/plans/TemplatePicker';
interface MembershipPlansComponentProps {
clubId: number;
@ -15,7 +16,9 @@ export default function MembershipPlansComponent({ clubId }: MembershipPlansComp
const [plans, setPlans] = useState<MembershipPlan[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<PlanTemplate | null>(null);
const [editingPlan, setEditingPlan] = useState<MembershipPlan | null>(null);
useEffect(() => {
@ -49,6 +52,22 @@ export default function MembershipPlansComponent({ clubId }: MembershipPlansComp
}
}
function handleCreateClick() {
// Open template picker first
setTemplatePickerOpen(true);
}
function handleTemplateSelect(template: PlanTemplate | null) {
setSelectedTemplate(template);
setTemplatePickerOpen(false);
setCreateModalOpen(true);
}
function handleCreateModalClose() {
setCreateModalOpen(false);
setSelectedTemplate(null);
}
return (
<div className="container mx-auto px-4 py-8">
{/* Header */}
@ -58,7 +77,7 @@ export default function MembershipPlansComponent({ clubId }: MembershipPlansComp
<p className="text-gray-600 mt-1">Create and manage subscription plans for your facility</p>
</div>
<button
onClick={() => setCreateModalOpen(true)}
onClick={handleCreateClick}
className="bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white px-6 py-3 rounded-lg font-semibold shadow-md transition-all duration-200"
>
+ Create Plan
@ -81,7 +100,7 @@ export default function MembershipPlansComponent({ clubId }: MembershipPlansComp
<h2 className="text-2xl font-semibold text-gray-700 mb-2">No membership plans yet</h2>
<p className="text-gray-500 mb-6">Create your first plan to get started</p>
<button
onClick={() => setCreateModalOpen(true)}
onClick={handleCreateClick}
className="bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white px-6 py-3 rounded-lg font-semibold shadow-md transition-all duration-200"
>
+ Create Your First Plan
@ -100,12 +119,27 @@ export default function MembershipPlansComponent({ clubId }: MembershipPlansComp
</div>
)}
{/* Template Picker Modal */}
<TemplatePicker
isOpen={templatePickerOpen}
onClose={() => setTemplatePickerOpen(false)}
onSelect={handleTemplateSelect}
/>
{/* Create Modal */}
{createModalOpen && (
<PlanFormModal
isOpen={createModalOpen}
onClose={() => setCreateModalOpen(false)}
onClose={handleCreateModalClose}
facilityId={clubId}
initialValues={selectedTemplate ? {
name: selectedTemplate.name,
billing_period: selectedTemplate.billing_period,
price_cents: selectedTemplate.suggested_price_cents,
sport_id: selectedTemplate.sport_id,
is_active: true
} : undefined}
templateName={selectedTemplate?.name}
onSuccess={fetchPlans}
/>
)}

@ -6,13 +6,23 @@ import ModalHeader from '@/src/components/modals/ModalHeader';
import ModalBody from '@/src/components/modals/ModalBody';
import ModalFooter from '@/src/components/modals/ModalFooter';
import { createPlan, updatePlan } from '@/src/lib/api/facility-admin';
import type { MembershipPlan, CreatePlanRequest, BillingPeriod } from '@/src/types/facility-admin';
import type { MembershipPlan, CreatePlanRequest, BillingPeriod, PlanTemplate } from '@/src/types/facility-admin';
interface InitialValues {
name: string;
billing_period: BillingPeriod;
price_cents: number;
sport_id?: number | null;
is_active?: boolean;
}
interface PlanFormModalProps {
isOpen: boolean;
onClose: () => void;
facilityId: number;
plan?: MembershipPlan;
initialValues?: InitialValues;
templateName?: string;
onSuccess: () => void;
}
@ -21,14 +31,17 @@ export default function PlanFormModal({
onClose,
facilityId,
plan,
initialValues,
templateName,
onSuccess
}: PlanFormModalProps) {
// Priority: plan (editing) > initialValues (from template) > defaults
const [formData, setFormData] = useState<CreatePlanRequest>({
name: plan?.name || '',
billing_period: plan?.billing_period || 'monthly',
price_cents: plan?.price_cents || 0,
sport_id: plan?.sport_id || null,
is_active: plan?.is_active ?? true
name: plan?.name ?? initialValues?.name ?? '',
billing_period: plan?.billing_period ?? initialValues?.billing_period ?? 'monthly',
price_cents: plan?.price_cents ?? initialValues?.price_cents ?? 0,
sport_id: plan?.sport_id ?? initialValues?.sport_id ?? null,
is_active: plan?.is_active ?? initialValues?.is_active ?? true
});
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -65,7 +78,7 @@ export default function PlanFormModal({
<Modal isOpen={isOpen} onClose={onClose} size="md">
<form onSubmit={handleSubmit}>
<ModalHeader onClose={onClose}>
{plan ? 'Edit Plan' : 'Create Plan'}
{plan ? 'Edit Plan' : templateName ? `Create Plan from "${templateName}"` : 'Create Plan'}
</ModalHeader>
<ModalBody>

@ -0,0 +1,66 @@
'use client';
import type { PlanTemplate } from '@/src/types/facility-admin';
interface TemplateCardProps {
template: PlanTemplate;
onSelect: () => void;
}
export default function TemplateCard({ template, onSelect }: TemplateCardProps) {
function formatPrice(cents: number): string {
if (cents === 0) return 'Free';
return `CHF ${(cents / 100).toFixed(0)}`;
}
function formatBillingPeriod(period: string | null): string {
if (!period) return '';
if (period === 'monthly') return '/month';
if (period === 'annual') return '/year';
return '';
}
return (
<div className="bg-white rounded-xl border-2 border-gray-200 hover:border-purple-400 transition-all duration-200 p-5 flex flex-col h-full">
{/* Template Name */}
<h3 className="font-bold text-lg text-gray-800 mb-1">
{template.name}
</h3>
{/* Price */}
<div className="text-2xl font-bold text-purple-600 mb-2">
{formatPrice(template.suggested_price_cents)}
<span className="text-sm font-normal text-gray-500">
{formatBillingPeriod(template.billing_period)}
</span>
</div>
{/* Description */}
{template.description && (
<p className="text-sm text-gray-600 mb-4 flex-grow">
{template.description}
</p>
)}
{/* Entitlements Preview */}
{Object.keys(template.default_entitlements).length > 0 && (
<div className="text-xs text-gray-500 mb-4 space-y-1">
{template.default_entitlements.max_active_bookings && (
<div>Max {template.default_entitlements.max_active_bookings} active bookings</div>
)}
{template.default_entitlements.advance_window_days && (
<div>Book {template.default_entitlements.advance_window_days} days ahead</div>
)}
</div>
)}
{/* Select Button */}
<button
onClick={onSelect}
className="w-full bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white py-2.5 rounded-lg font-semibold transition-all duration-200"
>
Use Template
</button>
</div>
);
}

@ -0,0 +1,122 @@
'use client';
import { useState, useEffect } from 'react';
import Modal from '@/src/components/modals/Modal';
import ModalHeader from '@/src/components/modals/ModalHeader';
import ModalBody from '@/src/components/modals/ModalBody';
import ModalFooter from '@/src/components/modals/ModalFooter';
import { listPlanTemplates } from '@/src/lib/api/facility-admin';
import type { PlanTemplate } from '@/src/types/facility-admin';
import TemplateCard from './TemplateCard';
interface TemplatePickerProps {
isOpen: boolean;
onClose: () => void;
onSelect: (template: PlanTemplate | null) => void;
}
export default function TemplatePicker({
isOpen,
onClose,
onSelect
}: TemplatePickerProps) {
const [templates, setTemplates] = useState<PlanTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isOpen) {
fetchTemplates();
}
}, [isOpen]);
async function fetchTemplates() {
setLoading(true);
setError(null);
const result = await listPlanTemplates();
if (result.success) {
setTemplates(result.data);
} else {
setError(result.error.detail || 'Failed to load templates');
}
setLoading(false);
}
function handleSelectTemplate(template: PlanTemplate) {
onSelect(template);
onClose();
}
function handleStartFromScratch() {
onSelect(null);
onClose();
}
return (
<Modal isOpen={isOpen} onClose={onClose} size="lg">
<ModalHeader onClose={onClose}>
Choose a Template
</ModalHeader>
<ModalBody>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-4">
{error}
</div>
)}
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin h-8 w-8 border-4 border-purple-500 border-t-transparent rounded-full" />
</div>
) : templates.length === 0 ? (
<div className="text-center py-8">
<p className="text-gray-500 mb-4">No templates available</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{templates.map(template => (
<TemplateCard
key={template.plan_template_id}
template={template}
onSelect={() => handleSelectTemplate(template)}
/>
))}
</div>
)}
{/* Divider */}
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-4 bg-white text-gray-500">or</span>
</div>
</div>
{/* Start from Scratch */}
<div className="text-center">
<button
onClick={handleStartFromScratch}
className="px-6 py-3 border-2 border-gray-300 hover:border-purple-400 text-gray-700 hover:text-purple-600 rounded-lg font-semibold transition-all duration-200"
>
Start from Scratch
</button>
</div>
</ModalBody>
<ModalFooter>
<button
onClick={onClose}
className="px-4 py-2 text-gray-600 hover:text-gray-800 font-medium transition-colors"
>
Cancel
</button>
</ModalFooter>
</Modal>
);
}

@ -20,6 +20,7 @@ import type {
UpdatePolicyRequest,
FacilityAdminError,
FacilityAdminApiResult,
PlanTemplate,
} from '@/src/types/facility-admin';
import apiFetch from '@/src/utils/apiFetch';
@ -234,6 +235,49 @@ export async function deletePlan(
}
}
// ============================================================================
// Plan Templates
// ============================================================================
/**
* GET /admin/plan-templates
* Get all available plan templates
*/
export async function listPlanTemplates(
filters?: { sport_id?: number }
): Promise<FacilityAdminApiResult<PlanTemplate[]>> {
try {
const params = new URLSearchParams();
if (filters?.sport_id !== undefined) params.set('sport_id', String(filters.sport_id));
const queryString = params.toString();
const endpoint = `/admin/plan-templates${queryString ? `?${queryString}` : ''}`;
const response = await apiFetch(endpoint, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
const result = await handleApiResponse<any>(response);
// Backend returns { templates: [...] } - extract the templates array
if (result.success && result.data.templates) {
return { success: true, data: result.data.templates };
}
return result as FacilityAdminApiResult<PlanTemplate[]>;
} catch (error) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Network Error',
status: 0,
detail: error instanceof Error ? error.message : 'Failed to list templates',
code: 'internal_error',
},
};
}
}
// ============================================================================
// Entitlements
// ============================================================================

@ -41,6 +41,21 @@ export interface UpdatePlanRequest {
is_active?: boolean;
}
// ============================================================================
// Plan Templates
// ============================================================================
export interface PlanTemplate {
plan_template_id: number;
name: string;
description: string | null;
suggested_price_cents: number;
billing_period: BillingPeriod;
default_entitlements: PlanEntitlements;
sport_id: number | null;
display_order: number;
}
// ============================================================================
// Entitlements
// ============================================================================

Loading…
Cancel
Save