Markdown Converter
Agent skill for markdown-converter
**Last Updated:** February 6, 2026
Sign in to like and favorite skills
Last Updated: February 6, 2026
Firebase Project: polyface-ae6d3
Production Domain: https://skedence.com
Status: Production (Live with Stripe payments)
A multi-tenant SaaS platform for fitness and sports organizations to manage:
IMPORTANT: All public URLs must use skedence.com domain. Never reference polyface-ae6d3.web.app in user-facing links.
Marketing Site: https://skedence.com
/web/index.html/admin-portal/Admin Portal: https://skedence.com/admin-portal/
/admin-portal/ (Next.js 16.1.4)/web/admin-portal/Password Setup: https://skedence.com/admin-portal/setup-password
/admin-portal/src/app/setup-password/File:
/web/firebase.json
{ "hosting": { "public": ".", "rewrites": [ { "source": "/admin-portal/**", "destination": "/admin-portal/index.html" }, { "source": "/**", "destination": "/index.html" } ] } }
How it works:
/admin-portal/** routes to admin portal Next.js app/**) serve marketing siteDeploy Everything:
./deploy-website.sh
What it does:
cd admin-portal && npm run buildcp -r admin-portal/out web/admin-portalcd web && firebase deploy --only hostingManual Steps:
# Build admin portal only cd admin-portal && npm run build # Deploy hosting only cd web && firebase deploy --only hosting # Deploy Cloud Functions cd SkedenceAdmin/functions && firebase deploy --only functions
/web/index.html/web/styles.css/web/images//web/firebase.json/admin-portal/src//admin-portal/src/app//admin-portal/src/components//admin-portal/next.config.ts/admin-portal/out/ (generated, not committed)/web/admin-portal/ (copied from build)/SkedenceAdmin/functions/src//SkedenceAdmin/functions/src/trainerInvitations.ts/SkedenceAdmin/functions/src/bookingAlerts.ts/SkedenceAdmin/functions/tsconfig.json/SkedenceAdmin/functions/.env (Stripe keys, NOT committed)/SkedenceAdmin/SkedenceAdmin//Skedence/Skedence/Always use skedence.com domain in:
Never use polyface-ae6d3.web.app in:
Admin Portal basePath:
'/admin-portal' in /admin-portal/next.config.tsDeployment Order:
organizations/{orgId} āāā name: string āāā adminEmail: string āāā createdAt: timestamp āāā isActive: boolean āāā subscriptionStatus: "active" | "trialing" | "past_due" | "canceled" āāā subscriptionTier: "starter" | "studio" | "academy" | "enterprise" āāā stripeCustomerId: string āāā stripeSubscriptionId: string āāā stripe: { ā āāā connectAccountId: string (Stripe Connect account ID) ā āāā publishableKey: string (org's publishable key) ā āāā onboardingComplete: boolean ā āāā chargesEnabled: boolean ā āāā payoutsEnabled: boolean ā āāā onboardingUrl: string ā } āāā branding: { ā āāā primaryColor: string (hex color) ā āāā logoUrl: string (optional) ā } āāā pricingStructure: { āāā tiers: [{ ā āāā id: string (UUID) ā āāā tierName: string (e.g., "Master", "Elite") ā āāā packages: [{ ā āāā id: string (UUID) ā āāā title: string (e.g., "1 Athlete Private Lesson") ā āāā priceInCents: number (e.g., 8000 = $80.00) ā āāā packageType: string (e.g., "private", "2_athlete") ā }] ā }] āāā lastUpdated: timestamp }
users/{userId} āāā firstName: string āāā lastName: string āāā email: string (or emailAddress) āāā phoneNumber: string āāā role: "admin" | "trainer" | "client" āāā orgId: string (PRIMARY - reference to organization) āāā organizationId: string (LEGACY - backwards compatibility only) āāā isActive: boolean āāā createdAt: timestamp āāā profileImageUrl?: string SUBCOLLECTION: packages (NEW STANDARD PATH) organizations/{orgId}/users/{userId}/packages/{packageId} āāā packageType: string (e.g., "private", "2_athlete", "class_10_pack") āāā packageCategory: string ("pass" or "class") āāā packageName?: string (optional display title) āāā totalLessons: number āāā lessonsUsed: number āāā remainingLessons: number (computed) āāā purchaseDate: timestamp āāā expirationDate: timestamp āāā transactionId: string āāā amountPaid: number (cents) āāā orgId: string SUBCOLLECTION: lessonPackages (LEGACY PATH - backwards compatibility) users/{userId}/lessonPackages/{packageId} (Same schema as packages above)
orgMembers/{memberId} āāā orgId: string āāā userId: string āāā role: "admin" | "trainer" | "client" āāā joinedAt: timestamp āāā isActive: boolean
trainers/{trainerId} āāā firstName: string āāā lastName: string āāā email: string āāā phoneNumber: string āāā orgId: string āāā active: boolean āāā role: "trainer" āāā needsPasswordSetup: boolean (true for new invitations) āāā setupToken: string (UUID for password setup) āāā setupTokenExpiry: timestamp (7 days from creation) āāā userId: string (Firebase Auth UID, added after password setup) āāā passwordSetAt: timestamp (when password was created) āāā createdAt: timestamp SUBCOLLECTION: schedules trainers/{trainerId}/schedules/{scheduleId} āāā startTime: timestamp āāā endTime: timestamp āāā isBooked: boolean āāā location?: string āāā notes?: string āāā createdAt: timestamp **NOTE:** No trainerId field in schedule docs (inherited from parent)
bookings/{bookingId} āāā clientId: string āāā trainerId: string āāā orgId: string āāā packageId: string (reference to lessonPackage) āāā scheduleId: string āāā startTime: timestamp āāā endTime: timestamp āāā status: "confirmed" | "cancelled" | "completed" āāā location?: string āāā notes?: string āāā createdAt: timestamp
classes/{classId} āāā orgId: string āāā trainerId: string āāā trainerName: string āāā className: string āāā description: string āāā startTime: timestamp āāā endTime: timestamp āāā location: string āāā maxParticipants: number āāā currentParticipants: number āāā participantIds: string[] (array of userIds) āāā isOpenForRegistration: boolean āāā price: number āāā imageUrl?: string āāā createdAt: timestamp
waivers/{waiverId} āāā clientId: string āāā orgId: string āāā signedAt: timestamp āāā waiverText: string āāā signature: string (base64 or URL) āāā ipAddress?: string
PRIMARY:
orgId (string)organizationId (string) - kept for backwards compatibility
Code Pattern:
// Cloud Functions - Always check both fields let orgId = userData.orgId as string | undefined; if (!orgId) { orgId = userData.organizationId as string | undefined; }
// Swift/iOS - Use orgId field let orgId = data["orgId"] as? String
PRIMARY:
organizations/{orgId}/users/{userId}/packages/{packageId}users/{userId}/lessonPackages/{packageId}
Reading Pattern:
// Try new path first let packageDoc = await db.collection("organizations") .doc(orgId).collection("users").doc(userId) .collection("packages").doc(packageId).get(); // Fallback to old path if (!packageDoc.exists) { packageDoc = await db.collection("users") .doc(userId).collection("lessonPackages") .doc(packageId).get(); }
// iOS - PackagesService uses dual-path query if let orgId = orgId { // Try new path first let newPath = db.collection("organizations") .document(orgId) .collection("users") .document(uid) .collection("packages") let snapshot = try await newPath.getDocuments() if !snapshot.isEmpty { return snapshot.documents // Found in new path } } // Fallback to legacy path let oldPath = db.collection("users") .document(uid) .collection("lessonPackages") let snapshot = try await oldPath.getDocuments()
Writing Pattern (Admin Functions):
// Write to BOTH paths for compatibility try await db.collection("users").document(userId) .collection("lessonPackages").addDocument(data: passData) try await db.collection("organizations").document(orgId) .collection("users").document(userId) .collection("packages").addDocument(data: passData)
{ packageType: string, // e.g., "private", "2_athlete", "class_10_pack" packageCategory: PackageCategory, // "oneAthlete" | "twoAthlete" | "threeAthlete" | "fourAthlete" | "classPass" packageName?: string, // Optional display title totalLessons: number, lessonsUsed: number, remainingLessons: number, // totalLessons - lessonsUsed purchaseDate: Timestamp, expirationDate: Timestamp, transactionId: string, amountPaid: number, // cents orgId: string // Required for multi-tenant isolation }
The
packageCategory field uses a strict enum to differentiate between athlete count for private lessons and group classes:
Enum Values:
oneAthlete - Private lesson for 1 athletetwoAthlete - Private lesson for 2 athletesthreeAthlete - Private lesson for 3 athletesfourAthlete - Private lesson for 4 athletesclassPass - Group class passDisplay Names (iOS):
enum PackageCategory: String, Codable, CaseIterable { case oneAthlete = "oneAthlete" case twoAthlete = "twoAthlete" case threeAthlete = "threeAthlete" case fourAthlete = "fourAthlete" case classPass = "class" var displayName: String { switch self { case .oneAthlete: return "1 Athlete" case .twoAthlete: return "2 Athletes" case .threeAthlete: return "3 Athletes" case .fourAthlete: return "4 Athletes" case .classPass: return "Class" } } var athleteCount: Int { (packageCategory: "oneAthlete") - `2_athlete` - Two athlete private lesson (packageCategory: "twoAthlete") - `3_athlete` - Three athlete private lesson (packageCategory: "threeAthlete") - `4_athlete` - Four athlete private lesson (packageCategory: "fourAthlete") - `class_pass` or `class` - Group class pass (packageCategory: "classPass") **NEW:** All packages now include a `packageCategory` field that uses the enum values above. The `packageType` field remains for backward compatibility and unique identification, but the `packageCategory` provides structured information about athlete count and lesson type. Organizations can create custom package types with any naming they want, but must assign one of the standard packageCategory values case .classPass: return 0 } } var isPrivateLesson: Bool { self != .classPass } }
Display Helper (Web):
function getCategoryDisplayName(category: string): string { switch (category) { case 'oneAthlete': return '1 Athlete'; case 'twoAthlete': return '2 Athletes'; case 'threeAthlete': return '3 Athletes'; case 'fourAthlete': return '4 Athletes'; case 'classPass': case 'class': return 'Class'; case 'pass': return 'Pass'; default: return category; } }
IMPORTANT: The enum provides type safety and consistent display across all platforms. Legacy "pass" and "class" values are supported for backward compatibility but new packages should use the specific athlete count categories.
The pricing structure is stored dynamically in Firestore at
organizations/{orgId}/pricingStructure and is used across both apps and Firebase Functions to ensure consistent pricing.
organizations/{orgId}/pricingStructure: { tiers: [ { id: string, // UUID tierName: string, // e.g., "Master", "Elite", "Pro" packages: [ { id: string, // UUID title: string, // e.g., "1 Athlete Private Lesson" priceInCents: number, // e.g., 8000 = $80.00 packageType: string // e.g., "private", "2_athlete", "3_athlete", "class_pass" } ] } ], lastUpdated: Date // ISO8601 timestamp }
private or 1_athlete - Single athlete private lesson2_athlete - Two athlete private lesson3_athlete - Three athlete private lessonclass_pass or class - Group class passOrganizations can create custom package types with any naming they want. Models/PricingStructure.swift`
struct PackageOption: Codable, Identifiable, Hashable { var id: String = UUID().uuidString var title: String // Display name var priceInCents: Int // Price in cents (8000 = $80) var packageType: String // Unique identifier var packageCategory: PackageCategory // Category enum (oneAthlete, twoAthlete, etc.) var lessonCount: Int = 1 // Number of lessons in package var description: String = "" // Package description var formattedPrice: String // "$80.00" var priceInDollars: Double // 80.0 mutating func autoGeneratePackageType() { if packageType.isEmpty { packageType = title.lowercased().replacingOccurrences(of: " ", with: "_") } }ue identifier var formattedPrice: String // "$80.00" var priceInDollars: Double // 80.0 } struct PricingTier: Codable, Identifiable, Hashable { var id: String = UUID().uuidString var tierName: String // "Master", "Elite", etc. var packages: [PackageOption] } struct PricingStructure: Codable { var tiers: [PricingTier] var lastUpdated: Date var allPackages: [PackageOption] // Flattened list func package(withTitle: String) -> PackageOption? }
Service:
Skedence/Skedence/PricingStructureService.swift
class PricingStructureService: ObservableObject { @Published var pricingStructure: PricingStructure? func loadPricingStructure(for orgId: String) async func savePricingStructure(_ structure: PricingStructure, for orgId: String) async throws var allPackageOptions: [PackageOption] var packageTitles: [String] func package(withTitle: String) -> PackageOption? }
1. During Onboarding (Step 6): File:
SkedenceAdmin/SkedenceAdmin/OnboardingPackagesView.swift
2. After Onboarding (Manage Tab): File:
SkedenceAdmin/SkedenceAdmin/AdminPanelView.swift
The Manage tab has a "Pricing Structure" section where owners/admins can:
File:
SkedenceAdmin/functions/src/stripe-connect.ts
// Load organization's pricing structure const validPackages: { [key: string]: number } = {}; if (orgData.pricingStructure?.tiers) { // Load from dynamic pricing structure for (const tier of orgData.pricingStructure.tiers) { for (const pkg of tier.packages) { validPackages[pkg.packageType] = pkg.priceInCents; } } console.log(`ā Loaded ${Object.keys(validPackages).length} packages from pricing structure`); } else { // Fallback to default pricing if no custom structure validPackages.private = 8000; validPackages["2_athlete"] = 12000; validPackages["3_athlete"] = 16000; validPackages.class_pass = 2000; } // Validate request matches pricing if (!validPackages[packageType] || validPackages[packageType] !== amount) { throw new functions.https.HttpsError( "invalid-argument", `Invalid package type or amount` ); }
If no pricing structure exists in Firestore, the app uses:
PricingStructure.default = [ Tier: "Standard" Packages: - "1 Athlete Private Lesson" - $80 (private) - "2 Athlete Private Lesson" - $120 (2_athlete) - "3 Athlete Private Lesson" - $160 (3_athlete) - "Class Pass" - $20 (class_pass) ]
Location:
/Skedence/SkedenceApp.swift - App entry pointAuthManager.swift - Firebase authentication, loads orgId, branding, Stripe keyBookingsService.swift - Booking CRUD operationsPackagesService.swift - Lesson pass management (dual-path queries)StripeService.swift - Payment processingPurchaseManager.swift - In-app purchase flowWaiverPDFGenerator.swift - Generate signed waiver PDFs with professional formattingActivityPDFGenerator.swift - Generate activity/liability waivers with custom brandingDesignSystem.swift - Brand colors and UI componentsPricingStructureService.swift - Load/save pricing structureModels/PricingStructure.swift - PackageCategory enum and pricing modelsModels/LessonPackage.swift - Package data model with category supportLocation:
/SkedenceAdmin/SkedenceAdminApp.swift - App entry point, deep link handlingClientsViewModel.swift - Client data managementClientDetailView.swift - Individual client managementAuthManager.swift - Role-based authentication, loads trainerIdAdminPanelView.swift - Manage tab with passes, classes, pricing, locationsFirestoreService.swift - Shared Firestore operationsScheduleView.swift - Trainer schedule with real-time updatesOnboardingLandingView.swift - Business signup landingCreateBusinessView.swift - Account creation flowStripeOnboardingView.swift - Stripe Connect wizardPasswordSetupView.swift - Password setup for invited trainersCRITICAL: Trainer document IDs ā Firebase Auth UIDs
@Published var trainerId: String? // Actual trainer document ID func loadTrainerId() async { guard let uid = Auth.auth().currentUser?.uid, let orgId = currentOrgId else { return } // Query trainers by orgId and email to find actual document ID let query = db.collection("trainers") .whereField("orgId", isEqualTo: orgId) .whereField("email", isEqualTo: userEmail) let snapshot = try? await query.getDocuments() if let doc = snapshot?.documents.first { trainerId = doc.documentID // This is the real trainer ID print("AuthManager: Loaded trainerId: \(trainerId) for user: \(uid)") } }
This is called when role is "trainer" to resolve the correct trainerId for schedule queries.
Location:
/admin-portal/ (Next.js source) and /web/admin-portal/ (deployed build)src/hooks/useAuth.tsx - Auth state management with anti-glitch pattern
// CRITICAL: Early return prevents re-renders if (firebaseUser && validatedUserId.current === firebaseUser.uid && hasCompletedInitialCheck.current) { return; // Already validated, don't update state }
src/components/dashboard-layout.tsx - Page wrapper with sidebarsrc/components/sidebar.tsx - Navigation with mobile hamburger menu
src/app/dashboard/page.tsx - Main dashboard with revenue calculationsrc/app/clients/page.tsx - Client list with searchsrc/app/trainers/page.tsx - Trainer list with searchsrc/app/schedule/page.tsx - Weekly schedule grid with real-time onSnapshotsrc/app/bookings/page.tsx - Create booking form with dual-path package queriessrc/app/passes/page.tsx - Assign/remove lesson packagessrc/app/pricing/page.tsx - Manage pricing structuresrc/app/classes/page.tsx - Group class managementsrc/app/analytics/page.tsx - Charts and metricslayout.tsx metadatatext-2xl sm:text-3xl, p-4 sm:p-6 lg:p-8active:shadow-xl for touch feedback// Trainers subcollection pattern const schedulesRef = collection(db, `trainers/${trainerId}/schedules`); const q = query(schedulesRef, where('isBooked', '==', false)); // Note: No trainerId filter needed - docs are already scoped to trainer // Real-time listeners for live updates const unsubscribe = onSnapshot(q, (snapshot) => { const data = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })); setSchedules(data); });
Location:
/SkedenceAdmin/functions/src/sendTrainerInvitation - Sends invitation email with password setup link
onDocumentCreated("trainers/{trainerId}")needsPasswordSetup === trueskedence://setup-password?token={token}&email={email}sendPasswordResetEmail - Password reset for existing users
deleteUserAccount - Delete user account and all data
bookLesson - Book a 1-on-1 lesson
registerForClass - Register for group class
manualRegisterForClass - Admin registers client for class
sendBookingAlerts - Sends booking confirmation emails
sendSummaryEmails - Daily/weekly summary emails
createConnectAccount - Creates Stripe Express account
createConnectAccountLink - Generates onboarding URL
refreshConnectAccountStatus - Updates Stripe status
createPaymentIntentConnect - Processes payment with platform fee
checkout.session.completedinvoice.payment_succeededaccount.updatedFile:
/SkedenceAdmin/functions/.env (NOT committed to git)
STRIPE_SECRET_KEY=sk_live_xxx STRIPE_PUBLISHABLE_KEY=pk_live_xxx STRIPE_WEBHOOK_SECRET=whsec_xxx STRIPE_STARTER_PRICE_ID=price_xxx STRIPE_STUDIO_PRICE_ID=price_xxx STRIPE_ACADEMY_PRICE_ID=price_xxx STRIPE_ENTERPRISE_PRICE_ID=price_xxx
cd SkedenceAdmin/functions npm run build firebase deploy --only functions
Or deploy specific function:
firebase deploy --only functions:sendTrainerInvitation
When an admin adds a new trainer, they receive an invitation email with a deep link to set up their password. This flow ensures trainers can securely create their accounts without the admin having to manually share passwords.
Admin Adds Trainer (SuperAdminViewModel.swift)
trainers/{trainerId} document with:
needsPasswordSetup: truesetupToken: UUID() (unique secure token)setupTokenExpiry: Date + 7 daysorgMembers/{trainerId}_{orgId} entry with roleCloud Function Triggered (trainerInvitations.ts)
onDocumentCreated("trainers/{trainerId}")needsPasswordSetup === trueskedence://setup-password?token={token}&email={email}&trainerId={id}Trainer Opens Email
skedence:// (registered in Info.plist)App Handles Deep Link (SkedenceAdminApp.swift)
onOpenURL parses query parameterspasswordSetupData statePasswordSetupViewPassword Setup Screen (PasswordSetupView.swift)
Password Creation Process
Auth.auth().createUser(withEmail:password:)userId: firebaseUid (links to Firebase Auth)needsPasswordSetup: falsepasswordSetAt: timestampsetupToken and setupTokenExpiry{firebaseUid}_{orgId}skedence://setup-passwordtoken, email, trainerIdCFBundleURLTypesskedence://setup-password?token=abc123&[email protected]&trainerId=xyz789Environment: LIVE MODE (Production)
Keys Location:
SkedenceAdmin/functions/.env
price_1SpKItFIh2MhEffNfsBy4HyTprice_1SpKMkFIh2MhEffNgGdbgMr5price_1SpKNrFIh2MhEffNqZf64sPAprice_1SpKOrFIh2MhEffNjU5v5X4PEach organization has their own Stripe Connect Express account:
Payment Flow:
Client pays $80 ā Stripe processes payment ā Platform takes $4 (5% application fee) ā Business receives $76 in their Connect account
Organization Stripe Fields:
stripe: { connectAccountId: "acct_xxx", // Stripe Connect account ID publishableKey: "pk_xxx", // Org's publishable key onboardingComplete: boolean, chargesEnabled: boolean, payoutsEnabled: boolean, onboardingUrl: string }
Client App Payment Flow:
StripeService.swift loads org's publishable key from FirestorecreatePaymentIntentConnect Cloud FunctionremainingLessons updatedKey Functions:
createPaymentIntentConnect - Creates payment intent with 5% feestripeConnectWebhook - Handles payment success eventsOnboarding Flow:
CreateBusinessView creates organizationStripeOnboardingViewCloud Functions:
createConnectAccount - Creates Express accountcreateConnectAccountLink - Generates onboarding URLrefreshConnectAccountStatus - Updates status from StripestripeConnectWebhook)checkout.session.completedinvoice.payment_succeededaccount.updatedpre-saas-migration branchesgs://polyface-ae6d3.firebasestorage.app/firestore-backups/20260107-184431-pre-saas-migrationorganizations collection0Mtow1OaV7oUlCisKSNy (Polyface Volleyball Academy)orgMembers collectionisMemberOfOrg(), hasOrgRole(), isOrgOwner(), etc.validate-multitenant.js scriptcreate-test-org.js for isolation testingOnboardingLandingViewCreateBusinessViewStripeOnboardingView wizardSkedence/firestore.rulesSkedenceAdmin/firestore.rulesā ļø IMPORTANT: Both files MUST be identical mirrors of each other!
These files secure the same Firebase project (polyface-ae6d3) and must always match. When updating security rules:
cd Skedence && firebase deploy --only firestore:rules cd SkedenceAdmin && firebase deploy --only firestore:rules
// Helper function: Check if user is member of org function isMemberOfOrg(orgId) { return exists(/databases/$(database)/documents/orgMembers/$(request.auth.uid + '_' + orgId)) && get(/databases/$(database)/documents/orgMembers/$(request.auth.uid + '_' + orgId)).data.isActive == true; } // Helper function: Check user's role in org function hasOrgRole(orgId, role) { return isMemberOfOrg(orgId) && get(/databases/$(database)/documents/orgMembers/$(request.auth.uid + '_' + orgId)).data.role == role; }
Organizations Collection:
match /organizations/{orgId} { // Changed from 'allow get' to 'allow read' to support snapshot listeners allow read: if isMemberOfOrg(orgId); allow update: if isOrgAdmin(orgId); }
Users Collection:
match /users/{userId} { allow read: if request.auth != null && ( request.auth.uid == userId || isMemberOfOrg(resource.data.orgId) ); allow write: if request.auth.uid == userId; // Packages subcollection match /lessonPackages/{packageId} { allow read, write: if request.auth.uid == userId; } }
Trainers Collection:
match /trainers/{trainerId} { // Self-read for schedule access allow get: if request.auth.uid == trainerId; // Org members can list trainers allow list: if request.auth != null && isMemberOfOrg(resource.data.orgId); // Schedules subcollection match /schedules/{slotId} { allow read: if request.auth != null && ( request.auth.uid == trainerId || resource.data.orgId in request.auth.token.orgIds || get(/databases/$(database)/documents/trainers/$(trainerId)).data.orgId in request.auth.token.orgIds ); } }
Bookings Collection:
match /bookings/{bookingId} { allow list: if request.auth != null; allow read: if request.auth != null && ( request.auth.uid == resource.data.clientId || request.auth.uid == resource.data.trainerId || isMemberOfOrg(resource.data.orgId) ); allow create: if request.auth != null; allow update, delete: if request.auth != null && ( request.auth.uid == resource.data.clientId || isOrgAdmin(resource.data.orgId) ); }
Classes Collection:
match /classes/{classId} { allow list: if request.auth != null; allow read, create, update, delete: if request.auth != null && isMemberOfOrg(resource.data.orgId); }
match /trainers/{trainerId} { // Allow reading trainer docs with needsPasswordSetup allow get: if resource.data.needsPasswordSetup == true; // Allow updating to link Firebase UID after password setup allow update: if request.auth != null && request.auth.uid != null && resource.data.needsPasswordSetup == true && request.resource.data.userId == request.auth.uid; }
# Use the deployment script (RECOMMENDED) ./deploy-website.sh # Or manual steps: cd admin-portal npm run build cd .. rm -rf web/admin-portal cp -r admin-portal/out web/admin-portal cd web firebase deploy --only hosting
cd SkedenceAdmin/functions npm run build firebase deploy --only functions # Deploy specific function firebase deploy --only functions:sendTrainerInvitation
# Deploy both (MUST be done together) cd Skedence && firebase deploy --only firestore:rules cd ../SkedenceAdmin && firebase deploy --only firestore:rules
cd admin-portal && npm run buildcp -r admin-portal/out web/admin-portalcd web && firebase servefirebase deploy --only hostingcd SkedenceAdmin/functions && firebase deploy --only functionsCause:
onAuthStateChanged triggers re-render ā setState ā re-render loopconst validatedUserId = useRef<string | null>(null); if (firebaseUser && validatedUserId.current === firebaseUser.uid) { return; // Already validated }
Cause: Filtering by
trainerId field that doesn't exist in subcollection// DON'T filter by trainerId const schedulesRef = collection(db, `trainers/${trainerId}/schedules`); const q = query(schedulesRef, where('isBooked', '==', false));
Cause: Debug logs in production build
Solution: Remove
console.log statements, keep only console.error
Cause: Fixed pixel widths, no responsive classes
Solution: Use Tailwind breakpoints, touch-manipulation class, min-h-[44px]
Symptom: Shows 4 default packages instead of custom pricing
Cause: Firestore rules used
allow get: instead of allow read:allow read: if isMemberOfOrg(orgId);
Cause: Auth UID ā Trainer document ID
Solution: AuthManager queries trainers by email to find actual document ID
@Published var trainerId: String? // Actual trainer document ID func loadTrainerId() async { // Query trainers collection by orgId and email // Sets trainerId to document ID (not Auth UID) }
Cause: Only checking new path, but data in old path
Solution: Implement dual-path query (new path first, fallback to old)
Cause: Client sent different amount than pricing structure
Fix:
// useAuth.tsx - Prevents infinite loops const validatedUserId = useRef<string | null>(null); const hasCompletedInitialCheck = useRef(false); // Early return before setState if (firebaseUser && validatedUserId.current === firebaseUser.uid) { return; // Already validated }
// Use onSnapshot for live updates const schedulesRef = collection(db, `trainers/${trainerId}/schedules`); const q = query(schedulesRef, where('isBooked', '==', false)); const unsubscribe = onSnapshot(q, (snapshot) => { const data = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })); setSchedules(data); }); return () => unsubscribe(); // Cleanup
// DON'T filter by parent ID - docs already scoped const schedulesRef = collection(db, `trainers/${trainerId}/schedules`); const q = query(schedulesRef, where('isBooked', '==', false)); // trainerId is implicit from path
// Try new path first if let orgId = orgId { let newPath = db.collection("organizations") .document(orgId) .collection("users") .document(uid) .collection("packages") let snapshot = try await newPath.getDocuments() if !snapshot.isEmpty { return snapshot.documents } } // Fallback to legacy path let oldPath = db.collection("users") .document(uid) .collection("lessonPackages") return try await oldPath.getDocuments()
// Tailwind mobile-first approach <h1 className="text-2xl sm:text-3xl lg:text-4xl"> <div className="p-4 sm:p-6 lg:p-8"> <button className="min-h-[44px] touch-manipulation">
@Published var primaryColor: Color = .blue @Published var logoUrl: String? @Published var stripePublishableKey: String? func loadOrgBranding(orgId: String) async { let doc = try? await db.collection("organizations") .document(orgId).getDocument() if let hex = doc?.data()?["branding.primaryColor"] as? String { primaryColor = Color(hex: hex) } if let key = doc?.data()?["stripe.publishableKey"] as? String { stripePublishableKey = key } }
// 5% platform fee const platformFeeAmount = Math.round(amount * 0.05); const paymentIntent = await stripe.paymentIntents.create({ amount: amount, currency: "usd", application_fee_amount: platformFeeAmount, transfer_data: { destination: connectAccountId, }, }); // Client pays $80 ā Platform $4 ā Business $76
{ "next": "16.1.4", "react": "^19.0.0", "firebase": "^11.2.0", "tailwindcss": "^3.4.1", "date-fns": "^4.1.0", "lucide-react": "^0.468.0", "recharts": "^2.15.0" }
skedence:// for iOS appsPackage Category Enum Implementation:
PDF Generation System:
WaiverPDFGenerator.swift - Professional waiver PDFs with signaturesActivityPDFGenerator.swift - Activity/liability waivers with custom brandingBooking Improvements:
Admin Portal Enhancements:
Code Refactoring:
allow get ā allow read for organizationsFor new conversations, read this file first to understand the complete project architecture and current state.