You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
PadelClub/PadelClub/Views/Tournament/Screen/RegistrationSetupView.swift

698 lines
29 KiB

//
// RegistrationSetupView.swift
// PadelClub
//
// Created by razmig on 20/11/2024.
//
import LeStorage
import SwiftUI
import PadelClubData
struct RegistrationSetupView: View {
@Environment(\.openURL) private var openURL
@EnvironmentObject var dataStore: DataStore
@Bindable var tournament: Tournament
@State private var enableOnlineRegistration: Bool
@State private var registrationDateLimit: Date
@State private var openingRegistrationDate: Date
@State private var targetTeamCount: Int
@State private var waitingListLimit: Int
@State private var registrationDateLimitEnabled: Bool
@State private var teamCountLimit: Bool
@State private var waitingListLimitEnabled: Bool
@State private var openingRegistrationDateEnabled: Bool
@State private var userAccountIsRequired: Bool
@State private var licenseIsRequired: Bool
@State private var minPlayerPerTeam: Int
@State private var maxPlayerPerTeam: Int
@State private var showMoreRegistrationInfos: Bool = false
@State private var showMoreOnlineWaitingListInfos: Bool = false
@State private var showMorePaymentInfos: Bool = false
@State private var enableTimeToConfirm: Bool
@State private var isTemplate: Bool
@State private var isCorporateTournament: Bool
@State private var isValidating = false
@State private var unregisterDeltaInHours: Int
// Online Payment
@State private var enableOnlinePayment: Bool
@State private var onlinePaymentIsMandatory: Bool
@State private var enableOnlinePaymentRefund: Bool
@State private var refundDateLimit: Date
@State private var refundDateLimitEnabled: Bool
@State private var stripeAccountId: String
@State private var stripeAccountIdIsInvalid: Bool?
@State private var paymentConfig: PaymentConfig?
@State private var timeToConfirmConfig: TimeToConfirmConfig?
@FocusState private var focusedField: Tournament.CodingKeys?
@State private var hasChanges: Bool = false
@State private var stripeOnBoardingURL: URL? = nil
@State private var errorMessage: String? = nil
@State private var presentErrorAlert: Bool = false
@Environment(\.dismiss) private var dismiss
init(tournament: Tournament) {
self.tournament = tournament
_enableOnlineRegistration = .init(wrappedValue: tournament.enableOnlineRegistration)
_isTemplate = .init(wrappedValue: tournament.isTemplate)
_isCorporateTournament = .init(wrappedValue: tournament.isCorporateTournament)
_unregisterDeltaInHours = .init(wrappedValue: tournament.unregisterDeltaInHours)
// Registration Date Limit
if let registrationDateLimit = tournament.registrationDateLimit {
_registrationDateLimit = .init(wrappedValue: registrationDateLimit)
_registrationDateLimitEnabled = .init(wrappedValue: true)
} else {
_registrationDateLimit = .init(wrappedValue: tournament.startDate.truncateMinutesAndSeconds())
_registrationDateLimitEnabled = .init(wrappedValue: false)
}
// Opening Registration Date
if let openingRegistrationDate = tournament.openingRegistrationDate {
_openingRegistrationDate = .init(wrappedValue: openingRegistrationDate)
_openingRegistrationDateEnabled = .init(wrappedValue: true)
} else {
_openingRegistrationDate = .init(wrappedValue: tournament.creationDate.truncateMinutesAndSeconds())
_openingRegistrationDateEnabled = .init(wrappedValue: false)
}
// Target Team Count
_targetTeamCount = .init(wrappedValue: tournament.teamCount) // Default value
_teamCountLimit = .init(wrappedValue: tournament.teamCountLimit)
// Waiting List Limit
if let waitingListLimit = tournament.waitingListLimit {
_waitingListLimit = .init(wrappedValue: waitingListLimit)
_waitingListLimitEnabled = .init(wrappedValue: true)
} else {
_waitingListLimit = .init(wrappedValue: 0) // Default value
_waitingListLimitEnabled = .init(wrappedValue: false)
}
_userAccountIsRequired = .init(wrappedValue: tournament.accountIsRequired)
_licenseIsRequired = .init(wrappedValue: tournament.licenseIsRequired)
_maxPlayerPerTeam = .init(wrappedValue: tournament.maximumPlayerPerTeam)
_minPlayerPerTeam = .init(wrappedValue: tournament.minimumPlayerPerTeam)
// Online Payment
_enableOnlinePayment = .init(wrappedValue: tournament.enableOnlinePayment)
_onlinePaymentIsMandatory = .init(wrappedValue: tournament.onlinePaymentIsMandatory)
_enableOnlinePaymentRefund = .init(wrappedValue: tournament.enableOnlinePaymentRefund)
_stripeAccountId = .init(wrappedValue: tournament.stripeAccountId ?? "")
_enableTimeToConfirm = .init(wrappedValue: tournament.enableTimeToConfirm)
// Refund Date Limit
if let refundDateLimit = tournament.refundDateLimit {
_refundDateLimit = .init(wrappedValue: refundDateLimit)
_refundDateLimitEnabled = .init(wrappedValue: true)
} else {
_refundDateLimit = .init(wrappedValue: tournament.startDate.truncateMinutesAndSeconds())
_refundDateLimitEnabled = .init(wrappedValue: false)
}
}
func displayWarning() -> Bool {
let unsortedTeamsCount = tournament.unsortedTeamsCount()
return tournament.shouldWarnOnlineRegistrationUpdates() && targetTeamCount != tournament.teamCount && (tournament.teamCount <= unsortedTeamsCount || targetTeamCount <= unsortedTeamsCount)
}
var body: some View {
List {
Section {
Toggle(isOn: $enableOnlineRegistration) {
Text("Activer")
}
} footer: {
VStack(alignment: .leading) {
Text("Les inscriptions en ligne permettent à des joueurs de s'inscrire à votre tournoi en passant par le site Padel Club. Vous verrez alors votre liste d'inscription s'agrandir dans la vue Gestion des Inscriptions de l'application.")
FooterButtonView("En savoir plus") {
self.showMoreRegistrationInfos = true
}
}
}
if enableOnlineRegistration {
Section {
Toggle(isOn: $isTemplate) {
Text("Définir en tant que réglages par défaut")
}
} footer: {
Text("Définisser ce tournoi comme la source de vos réglages concernant l'inscription en ligne. Tous les tournois crées après celui-ci utiliseront ces réglages.")
}
if let shareURL = tournament.shareURL(.info) {
Section {
Link(destination: shareURL) {
Text(shareURL.absoluteString)
}
} header: {
Text("Page d'inscription")
} footer: {
HStack {
CopyPasteButtonView(pasteValue: shareURL.absoluteString)
Spacer()
ShareLink(item: shareURL) {
Label("Partager", systemImage: "square.and.arrow.up")
}
}
}
}
if dataStore.user.canEnableOnlinePayment() {
Section {
Toggle(isOn: $enableTimeToConfirm) {
Text("Confirmation obligatoire")
}
} header: {
Text("Procédure de la liste d'attente")
} footer: {
VStack(alignment: .leading) {
Text("Si activé, les équipes sortant de la liste d'attente et entrant dans le tournoi auront un temps pre-determiné pour confirmer leur changement de statut sinon l'équipe suivante de la liste sera prévenu automatiquement. Si désactivé, une équipe devra indiquer si elle n'est plus disponible pour que la liste d'attente passe à la prochaine équipe.")
FooterButtonView("En savoir plus") {
self.showMoreOnlineWaitingListInfos = true
}
}
}
.task {
do {
self.timeToConfirmConfig = try await ConfigurationService.fetchTournamentConfig()
} catch {
print("Error fetching timeToConfirmConfig: \(error)")
}
}
}
Section {
Text("Par défaut, sans date définie, les inscriptions en ligne sont possible dès son activation.")
Toggle(isOn: $openingRegistrationDateEnabled) {
Text("Définir une date ultérieur")
}
if openingRegistrationDateEnabled {
DatePicker(selection: $openingRegistrationDate) {
DateMenuView(date: $openingRegistrationDate)
}
}
} header: {
Text("Date d'ouverture des inscriptions")
} footer: {
Text("Activez et définissez une date d'ouverture pour les inscriptions au tournoi. Les inscriptions en ligne ne seront possible qu'à partir de cette date.")
}
Section {
Toggle(isOn: $registrationDateLimitEnabled) {
Text("Définir une date")
}
if registrationDateLimitEnabled {
DatePicker(selection: $registrationDateLimit) {
DateMenuView(date: $registrationDateLimit)
}
}
} header: {
Text("Date de fermeture des inscriptions")
} footer: {
Text("Si une date de fermeture des inscriptions en ligne est définie, alors plus aucune inscription ne sera possible après cette date. Sinon, la date du début du tournoi ou la date de clôture des inscriptions seront utilisées.")
}
Section {
LabeledContent {
StepperView(count: $unregisterDeltaInHours)
} label: {
Text("\(unregisterDeltaInHours)h avant")
}
} header: {
Text("Limite de désinscription")
} footer: {
Text("Empêche la désinscription plusieurs heures avant le début du tournoi")
}
Section {
if displayWarning() {
Text("Attention, l'inscription en ligne est activée et vous avez des équipes inscrites en ligne, en modifiant la structure ces équipes seront intégrées ou retirées de votre sélection d'équipes. Padel Club saura prévenir les équipes inscrites en ligne automatiquement.")
.foregroundStyle(.logoRed)
}
Toggle(isOn: $teamCountLimit) {
Text("Activer une limite")
}
if teamCountLimit {
StepperView(count: $targetTeamCount, minimum: 4)
}
} header: {
Text("Paires admises")
} footer: {
Text("Les inscriptions seront indiquées en attente pour les joueurs au-délà de cette limite dans le cas où aucune limite de liste d'attente n'est active ou non atteinte. Dans le cas contraire, plus aucune inscription ne seront possibles.")
}
Section {
Toggle(isOn: $waitingListLimitEnabled) {
Text("Activer une limite")
}
if waitingListLimitEnabled {
StepperView(count: $waitingListLimit, minimum: 0)
}
} header: {
Text("Liste d'attente")
} footer: {
Text("Si une limite à la liste d'attente existe, les inscriptions ne seront plus possibles une fois la liste d'attente pleine. Si aucune limite de liste d'attente n'est active, alors les inscriptions seront toujours possibles. Les joueurs auront une indication comme quoi ils sont en liste d'attente.")
}
if dataStore.user.canEnableOnlinePayment() {
_onlinePaymentsView()
}
if tournament.isAnimation() {
Section {
Toggle(isOn: $userAccountIsRequired) {
Text("Compte Padel Club requis pour s'inscrire")
}
Toggle(isOn: $licenseIsRequired) {
Text("Licence FFT requise pour s'inscrire")
}
LabeledContent {
StepperView(count: $minPlayerPerTeam, minimum: 1, maximum: maxPlayerPerTeam)
} label: {
Text("Nombre minimum de joueurs possible")
}
LabeledContent {
StepperView(count: $maxPlayerPerTeam, minimum: minPlayerPerTeam)
} label: {
Text("Nombre maximum de joueurs possible")
}
}
}
} else {
ContentUnavailableView(
"Activez les inscriptions en ligne",
systemImage: "person.2.crop.square.stack.fill",
description: Text("Permettez aux joueurs de s'inscrire eux-mêmes à ce tournoi. Les équipes inscrites apparaîtront automatiquement dans la liste de l'arbitre. L'inscription en ligne requiert un email de contact et une licence FFT.")
)
}
}
.sheet(isPresented: $showMoreRegistrationInfos) {
RegistrationInfoSheetView()
}
.sheet(isPresented: $showMoreOnlineWaitingListInfos) {
OnlineWaitingListFaqSheetView(timeToConfirmConfig: timeToConfirmConfig ?? TimeToConfirmConfig.defaultConfig)
}
.sheet(isPresented: $showMorePaymentInfos) {
PaymentInfoSheetView(paymentConfig: paymentConfig ?? PaymentConfig.defaultConfig)
}
.toolbar(content: {
if hasChanges {
ToolbarItem(placement: .topBarLeading) {
Button("Annuler", role: .cancel) {
dismiss()
}
}
ToolbarItem(placement: .topBarTrailing) {
ButtonValidateView(role: .destructive) {
_save()
}
}
}
})
.toolbar {
if focusedField == ._stripeAccountId, stripeAccountId.isEmpty == false {
ToolbarItem(placement: .keyboard) {
HStack {
Button("Effacer") {
stripeAccountId = ""
stripeAccountIdIsInvalid = nil
tournament.stripeAccountId = nil
}
.buttonStyle(.borderless)
Spacer()
Button("Valider") {
focusedField = nil
}
.buttonStyle(.bordered)
}
}
}
}
.alert("Paiement en ligne", isPresented: $presentErrorAlert, actions: {
Button("Fermer") {
self.presentErrorAlert = false
}
}, message: {
Text(ValidationError.onlinePaymentNotEnabled.localizedDescription)
})
.headerProminence(.increased)
.navigationTitle("Inscription en ligne")
.ifAvailableiOS26 {
if #available(iOS 26.0, *) {
$0.navigationSubtitle(tournament.tournamentTitle())
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.visible, for: .navigationBar)
.navigationBarBackButtonHidden(hasChanges)
.onChange(of: enableOnlineRegistration, {
_hasChanged()
})
.onChange(of: openingRegistrationDateEnabled) {
_hasChanged()
}
.onChange(of: openingRegistrationDate) {
_hasChanged()
}
.onChange(of: registrationDateLimitEnabled) {
_hasChanged()
}
.onChange(of: registrationDateLimit) {
_hasChanged()
}
.onChange(of: teamCountLimit) {
_hasChanged()
}
.onChange(of: targetTeamCount) {
_hasChanged()
}
.onChange(of: waitingListLimitEnabled) {
_hasChanged()
}
.onChange(of: waitingListLimit) {
_hasChanged()
}
.onChange(of: [minPlayerPerTeam, maxPlayerPerTeam]) {
_hasChanged()
}
.onChange(of: [isTemplate, userAccountIsRequired, licenseIsRequired, enableTimeToConfirm, isCorporateTournament]) {
_hasChanged()
}
}
@ViewBuilder
private func _onlinePaymentsView() -> some View {
let entryFee = tournament.entryFee ?? 20
Section {
Toggle(isOn: $enableOnlinePayment) {
Text("Activer le paiement en ligne")
Text("Cette fonction peut entraîner un coût supplémentaire.")
.foregroundStyle(.logoRed)
.bold()
}
if enableOnlinePayment {
Toggle(isOn: $onlinePaymentIsMandatory) {
Text("Paiement obligatoire")
}
}
Toggle(isOn: $enableOnlinePaymentRefund) {
Text("Autoriser les remboursements en ligne")
}
if enableOnlinePaymentRefund {
Toggle(isOn: $refundDateLimitEnabled) {
Text("Définir une date limite")
}
if refundDateLimitEnabled {
DatePicker(selection: $refundDateLimit) {
DateMenuView(date: $refundDateLimit)
}
}
}
if dataStore.user.registrationPaymentMode == .corporate {
Toggle(isOn: $isCorporateTournament) {
Text("Revenu Padel Club")
}
}
} header: {
Text("Paiement en ligne")
} footer: {
VStack(alignment: .leading) {
Text("Permettez aux joueurs de payer leur inscription en ligne. Vous devez connecter un compte Stripe pour recevoir les paiements.")
FooterButtonView("En savoir plus") {
self.showMorePaymentInfos = true
}
}
}
.task {
do {
self.paymentConfig = try await ConfigurationService.fetchPaymentConfig()
} catch {
print("Error fetching paymentConfig: \(error)")
}
}
.onChange(of: [enableOnlinePayment, onlinePaymentIsMandatory, enableOnlinePaymentRefund]) {
_hasChanged()
}
.onChange(of: refundDateLimitEnabled) {
_hasChanged()
}
.onChange(of: refundDateLimit) {
_hasChanged()
}
if isCorporateTournament == false, dataStore.user.registrationPaymentMode.requiresStripe() {
Section {
LabeledContent {
if isValidating {
ProgressView()
} else if focusedField == nil, stripeAccountIdIsInvalid == false, stripeAccountId.isEmpty == false, isValidating == false {
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
}
} label: {
TextField("Identifiant du compte Stripe", text: $stripeAccountId)
.frame(maxWidth: .infinity)
.focused($focusedField, equals: ._stripeAccountId)
.disabled(isValidating)
.keyboardType(.alphabet)
.textContentType(nil)
.autocorrectionDisabled()
}
.onChange(of: focusedField) { old, new in
if old == ._stripeAccountId {
_confirmStripeAccountId()
}
}
if stripeAccountIdIsInvalid == true {
Text("Identifiant Stripe invalide. Vous ne pouvez pas activer le paiement en ligne.").foregroundStyle(.logoRed)
}
if stripeAccountId.isEmpty == false {
Button("Vérifier le compte Stripe") {
_confirmStripeAccountId()
}
.disabled(isValidating)
}
if let errorMessage {
Text(errorMessage).foregroundStyle(.logoRed)
}
RowButtonView("Connecter ou créer un compte Stripe", role: .destructive) {
errorMessage = nil
stripeAccountIdIsInvalid = nil
stripeAccountId = ""
stripeOnBoardingURL = nil
do {
let createStripeAccountResponse = try await StripeValidationService.createStripeConnectAccount()
print("createStripeAccountResponse", createStripeAccountResponse)
guard let accounId = createStripeAccountResponse.accountId else {
throw ValidationError.accountNotFound
}
let createStripeAccountLinkResponse = try await StripeValidationService.createStripeAccountLink(accounId)
print("createStripeAccountLinkResponse", createStripeAccountLinkResponse)
stripeOnBoardingURL = createStripeAccountLinkResponse.url
stripeAccountIdIsInvalid = nil
stripeAccountId = accounId
if let stripeOnBoardingURL {
openURL(stripeOnBoardingURL)
} else {
throw ValidationError.urlNotFound
}
} catch {
self.errorMessage = error.localizedDescription
Logger.error(error)
}
}
} header: {
Text("Compte Stripe")
} footer: {
Text("Vous devez connecter un compte Stripe à Padel Club. En cliquant sur le bouton ci-dessus, vous serez dirigé vers Stripe pour choisir votre compte Stripe à connecter ou pour en créer un.")
}
Section {
let fixedFee = RegistrationPaymentMode.stripeFixedFee // Fixed fee in euros
let percentageFee = RegistrationPaymentMode.stripePercentageFee
let totalStripeFee = fixedFee + (entryFee * percentageFee)
LabeledContent {
Text("\(fixedFee, format: .currency(code: "EUR")) + \(percentageFee, format: .percent)")
} label: {
Text("Commission Stripe")
}
Text("Soit \(totalStripeFee, format: .currency(code: "EUR")) pour \(entryFee, format: .currency(code: "EUR")).")
}
}
Section {
if isCorporateTournament == false, dataStore.user.registrationPaymentMode.requiresStripe(), dataStore.user.registrationPaymentMode.hasPadelClubFee() {
let padelClubFee = paymentConfig?.stripeFee ?? PaymentConfig.defaultConfig.stripeFee
let feeAmount = entryFee * padelClubFee
LabeledContent {
Text(padelClubFee, format: .percent)
} label: {
Text("Commission Padel Club")
}
Text("Soit \(feeAmount, format: .currency(code: "EUR")) pour \(entryFee, format: .currency(code: "EUR")).")
// } else {
// Text("Aucune commission Padel Club ne sera prélevée.").foregroundStyle(.logoRed).bold()
}
}
}
private func _confirmStripeAccountId() {
stripeAccountIdIsInvalid = nil
if stripeAccountId.isEmpty {
tournament.stripeAccountId = nil
} else if stripeAccountId.count >= 5, stripeAccountId.starts(with: "acct_") {
_checkStripeAccount(stripeAccountId.prefixMultilineTrimmed(255))
} else {
stripeAccountIdIsInvalid = true
}
}
private func _checkStripeAccount(_ accId: String) {
Task {
isValidating = true
do {
let response = try await StripeValidationService.validateStripeAccount(accountId: accId)
print("validateStripeAccount", response)
stripeAccountId = accId
stripeAccountIdIsInvalid = response.canProcessPayments == false
} catch {
stripeAccountIdIsInvalid = true
}
isValidating = false
}
}
private func _hasChanged() {
hasChanges = true
}
private func _save() {
hasChanges = false
tournament.enableOnlineRegistration = enableOnlineRegistration
tournament.isTemplate = isTemplate
tournament.isCorporateTournament = isCorporateTournament
tournament.unregisterDeltaInHours = unregisterDeltaInHours
var shouldDismiss = true
if enableOnlineRegistration {
tournament.accountIsRequired = userAccountIsRequired
tournament.licenseIsRequired = licenseIsRequired
tournament.minimumPlayerPerTeam = minPlayerPerTeam
tournament.maximumPlayerPerTeam = maxPlayerPerTeam
// Online Payment
tournament.enableOnlinePayment = enableOnlinePayment
tournament.onlinePaymentIsMandatory = onlinePaymentIsMandatory
tournament.enableOnlinePaymentRefund = enableOnlinePaymentRefund
if refundDateLimitEnabled == false {
tournament.refundDateLimit = nil
} else {
tournament.refundDateLimit = refundDateLimit
}
tournament.enableTimeToConfirm = enableTimeToConfirm
if stripeAccountIdIsInvalid == false {
tournament.stripeAccountId = stripeAccountId
} else {
tournament.stripeAccountId = nil
if enableOnlinePayment, isCorporateTournament == false, dataStore.user.registrationPaymentMode.requiresStripe() {
enableOnlinePayment = false
tournament.enableOnlinePayment = false
shouldDismiss = false
}
}
} else {
tournament.accountIsRequired = true
tournament.licenseIsRequired = true
tournament.minimumPlayerPerTeam = 2
tournament.maximumPlayerPerTeam = 2
tournament.enableTimeToConfirm = false
// When online registration is disabled, also disable online payment
tournament.enableOnlinePayment = false
tournament.onlinePaymentIsMandatory = false
tournament.enableOnlinePaymentRefund = false
tournament.refundDateLimit = nil
tournament.stripeAccountId = nil
}
if openingRegistrationDateEnabled == false {
tournament.openingRegistrationDate = nil
} else {
tournament.openingRegistrationDate = openingRegistrationDate
}
if registrationDateLimitEnabled == false {
tournament.registrationDateLimit = nil
} else {
tournament.registrationDateLimit = registrationDateLimit
}
tournament.teamCountLimit = teamCountLimit
tournament.teamCount = targetTeamCount
if waitingListLimitEnabled == false {
tournament.waitingListLimit = nil
} else {
tournament.waitingListLimit = waitingListLimit
}
self.dataStore.tournaments.addOrUpdate(instance: tournament)
if shouldDismiss {
dismiss()
} else {
presentErrorAlert = true
}
}
}