Refactor purchases and payments info

sync3
Laurent 6 months ago
parent 9547d13349
commit d910ca1646
  1. 14
      PadelClub.xcodeproj/project.pbxproj
  2. 8
      PadelClub/Data/DataStore.swift
  3. 4
      PadelClub/Extensions/Date+Extensions.swift
  4. 3
      PadelClub/Views/Planning/PlanningSettingsView.swift
  5. 19
      PadelClub/Views/Tournament/Subscription/Guard.swift
  6. 99
      PadelClub/Views/Tournament/Subscription/PaymentStatusView.swift
  7. 26
      PadelClub/Views/Tournament/Subscription/PurchaseListView.swift
  8. 2
      PadelClub/Views/Tournament/Subscription/SubscriptionInfoView.swift
  9. 3
      PadelClub/Views/Tournament/TournamentView.swift

@ -161,6 +161,10 @@
C4C01D982C481C0C0059087C /* CapsuleViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C01D972C481C0C0059087C /* CapsuleViewModifier.swift */; }; C4C01D982C481C0C0059087C /* CapsuleViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C01D972C481C0C0059087C /* CapsuleViewModifier.swift */; };
C4C33F762C9B1ED4006316DE /* CodingContainer+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C33F752C9B1EC5006316DE /* CodingContainer+Extensions.swift */; }; C4C33F762C9B1ED4006316DE /* CodingContainer+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C33F752C9B1EC5006316DE /* CodingContainer+Extensions.swift */; };
C4C33F772C9B1ED4006316DE /* CodingContainer+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C33F752C9B1EC5006316DE /* CodingContainer+Extensions.swift */; }; C4C33F772C9B1ED4006316DE /* CodingContainer+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C33F752C9B1EC5006316DE /* CodingContainer+Extensions.swift */; };
C4D05D472DC10AE5009B053C /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C4D05D462DC10AE5009B053C /* WebKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
C4D05D492DC10CBE009B053C /* PaymentStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D05D482DC10CBE009B053C /* PaymentStatusView.swift */; };
C4D05D4A2DC10CBE009B053C /* PaymentStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D05D482DC10CBE009B053C /* PaymentStatusView.swift */; };
C4D05D4B2DC10CBE009B053C /* PaymentStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D05D482DC10CBE009B053C /* PaymentStatusView.swift */; };
C4D477992CB6704C0077713D /* SynchronizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D477982CB6704C0077713D /* SynchronizationTests.swift */; }; C4D477992CB6704C0077713D /* SynchronizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D477982CB6704C0077713D /* SynchronizationTests.swift */; };
C4EC6F572BE92CAC000CEAB4 /* local.plist in Resources */ = {isa = PBXBuildFile; fileRef = C4EC6F562BE92CAC000CEAB4 /* local.plist */; }; C4EC6F572BE92CAC000CEAB4 /* local.plist in Resources */ = {isa = PBXBuildFile; fileRef = C4EC6F562BE92CAC000CEAB4 /* local.plist */; };
C4EC6F592BE92D88000CEAB4 /* PListReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4EC6F582BE92D88000CEAB4 /* PListReader.swift */; }; C4EC6F592BE92D88000CEAB4 /* PListReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4EC6F582BE92D88000CEAB4 /* PListReader.swift */; };
@ -1144,6 +1148,8 @@
C4B3A1542C2581DA0078EAA8 /* Patcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Patcher.swift; sourceTree = "<group>"; }; C4B3A1542C2581DA0078EAA8 /* Patcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Patcher.swift; sourceTree = "<group>"; };
C4C01D972C481C0C0059087C /* CapsuleViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapsuleViewModifier.swift; sourceTree = "<group>"; }; C4C01D972C481C0C0059087C /* CapsuleViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapsuleViewModifier.swift; sourceTree = "<group>"; };
C4C33F752C9B1EC5006316DE /* CodingContainer+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CodingContainer+Extensions.swift"; sourceTree = "<group>"; }; C4C33F752C9B1EC5006316DE /* CodingContainer+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CodingContainer+Extensions.swift"; sourceTree = "<group>"; };
C4D05D462DC10AE5009B053C /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; };
C4D05D482DC10CBE009B053C /* PaymentStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentStatusView.swift; sourceTree = "<group>"; };
C4D477982CB6704C0077713D /* SynchronizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynchronizationTests.swift; sourceTree = "<group>"; }; C4D477982CB6704C0077713D /* SynchronizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynchronizationTests.swift; sourceTree = "<group>"; };
C4EC6F562BE92CAC000CEAB4 /* local.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = local.plist; sourceTree = "<group>"; }; C4EC6F562BE92CAC000CEAB4 /* local.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = local.plist; sourceTree = "<group>"; };
C4EC6F582BE92D88000CEAB4 /* PListReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PListReader.swift; sourceTree = "<group>"; }; C4EC6F582BE92D88000CEAB4 /* PListReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PListReader.swift; sourceTree = "<group>"; };
@ -1458,6 +1464,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
C4D05D472DC10AE5009B053C /* WebKit.framework in Frameworks */,
FFCFBFFE2BBBE86600B82851 /* Algorithms in Frameworks */, FFCFBFFE2BBBE86600B82851 /* Algorithms in Frameworks */,
FF92660D2C241CE0002361A4 /* Zip in Frameworks */, FF92660D2C241CE0002361A4 /* Zip in Frameworks */,
C49EF0392BDFF4600077B5AA /* LeStorage.framework in Frameworks */, C49EF0392BDFF4600077B5AA /* LeStorage.framework in Frameworks */,
@ -1583,6 +1590,7 @@
C425D4592B6D255B002A7B48 /* Frameworks */ = { C425D4592B6D255B002A7B48 /* Frameworks */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
C4D05D462DC10AE5009B053C /* WebKit.framework */,
C49EF0372BDFF3000077B5AA /* LeStorage.framework */, C49EF0372BDFF3000077B5AA /* LeStorage.framework */,
); );
name = Frameworks; name = Frameworks;
@ -1706,6 +1714,7 @@
C4A47D8F2B7BBBEC00ADC637 /* StoreItem.swift */, C4A47D8F2B7BBBEC00ADC637 /* StoreItem.swift */,
C49EF0182BD694290077B5AA /* PurchaseListView.swift */, C49EF0182BD694290077B5AA /* PurchaseListView.swift */,
C49EF0252BD80AE80077B5AA /* SubscriptionInfoView.swift */, C49EF0252BD80AE80077B5AA /* SubscriptionInfoView.swift */,
C4D05D482DC10CBE009B053C /* PaymentStatusView.swift */,
); );
path = Subscription; path = Subscription;
sourceTree = "<group>"; sourceTree = "<group>";
@ -2691,6 +2700,7 @@
C488C8342CC7E4240082001F /* BaseRound.swift in Sources */, C488C8342CC7E4240082001F /* BaseRound.swift in Sources */,
C488C8352CC7E4240082001F /* BaseMatchScheduler.swift in Sources */, C488C8352CC7E4240082001F /* BaseMatchScheduler.swift in Sources */,
C488C8372CC7E4240082001F /* BasePlayerRegistration.swift in Sources */, C488C8372CC7E4240082001F /* BasePlayerRegistration.swift in Sources */,
C4D05D492DC10CBE009B053C /* PaymentStatusView.swift in Sources */,
C488C8382CC7E4240082001F /* BaseTeamScore.swift in Sources */, C488C8382CC7E4240082001F /* BaseTeamScore.swift in Sources */,
FFE8B5BB2DA9896800BDE966 /* RefundService.swift in Sources */, FFE8B5BB2DA9896800BDE966 /* RefundService.swift in Sources */,
C488C8392CC7E4240082001F /* BaseTournament.swift in Sources */, C488C8392CC7E4240082001F /* BaseTournament.swift in Sources */,
@ -3152,6 +3162,7 @@
FFE8B5CC2DAA42A000BDE966 /* XlsToCsvService.swift in Sources */, FFE8B5CC2DAA42A000BDE966 /* XlsToCsvService.swift in Sources */,
FF3A73F52D37C34D007E3032 /* RegistrationInfoSheetView.swift in Sources */, FF3A73F52D37C34D007E3032 /* RegistrationInfoSheetView.swift in Sources */,
FF4CBFF02C996C0600151637 /* URLs.swift in Sources */, FF4CBFF02C996C0600151637 /* URLs.swift in Sources */,
C4D05D4A2DC10CBE009B053C /* PaymentStatusView.swift in Sources */,
FF4CBFF12C996C0600151637 /* MatchDescriptor.swift in Sources */, FF4CBFF12C996C0600151637 /* MatchDescriptor.swift in Sources */,
FF4CBFF22C996C0600151637 /* TournamentFormatSelectionView.swift in Sources */, FF4CBFF22C996C0600151637 /* TournamentFormatSelectionView.swift in Sources */,
FF17CA592CC02FEB003C7323 /* CoachListView.swift in Sources */, FF17CA592CC02FEB003C7323 /* CoachListView.swift in Sources */,
@ -3452,6 +3463,7 @@
FFE8B5CB2DAA42A000BDE966 /* XlsToCsvService.swift in Sources */, FFE8B5CB2DAA42A000BDE966 /* XlsToCsvService.swift in Sources */,
FF3A73F42D37C34D007E3032 /* RegistrationInfoSheetView.swift in Sources */, FF3A73F42D37C34D007E3032 /* RegistrationInfoSheetView.swift in Sources */,
FF70FB6F2C90584900129CC2 /* URLs.swift in Sources */, FF70FB6F2C90584900129CC2 /* URLs.swift in Sources */,
C4D05D4B2DC10CBE009B053C /* PaymentStatusView.swift in Sources */,
FF70FB702C90584900129CC2 /* MatchDescriptor.swift in Sources */, FF70FB702C90584900129CC2 /* MatchDescriptor.swift in Sources */,
FF70FB712C90584900129CC2 /* TournamentFormatSelectionView.swift in Sources */, FF70FB712C90584900129CC2 /* TournamentFormatSelectionView.swift in Sources */,
FF17CA582CC02FEB003C7323 /* CoachListView.swift in Sources */, FF17CA582CC02FEB003C7323 /* CoachListView.swift in Sources */,
@ -3721,6 +3733,7 @@
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO; SUPPORTS_MACCATALYST = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
}; };
@ -3766,6 +3779,7 @@
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO; SUPPORTS_MACCATALYST = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
}; };

@ -392,4 +392,12 @@ class DataStore: ObservableObject {
return _cachedEndMatches! return _cachedEndMatches!
} }
func subscriptionUnitlyPayedTournaments(after date: Date) -> Int {
return DataStore.shared.tournaments.count(where: { tournament in
tournament.creationDate > date &&
tournament.payment == .subscriptionUnit &&
tournament.isCanceled == false &&
tournament.isDeleted == false })
}
} }

@ -56,6 +56,10 @@ extension Date {
formatted(.dateTime.weekday().day(.twoDigits).month().year()) formatted(.dateTime.weekday().day(.twoDigits).month().year())
} }
var dateFormatted: String {
formatted(.dateTime.day(.twoDigits).month(.twoDigits).year(.twoDigits))
}
var monthYearFormatted: String { var monthYearFormatted: String {
formatted(.dateTime.month(.wide).year(.defaultDigits)) formatted(.dateTime.month(.wide).year(.defaultDigits))
} }

@ -48,7 +48,8 @@ struct PlanningSettingsView: View {
var body: some View { var body: some View {
List { List {
if tournament.payment == nil { if tournament.payment == nil {
SubscriptionInfoView() ImageInfoView()
// SubscriptionInfoView()
} }
Section { Section {

@ -20,7 +20,7 @@ import LeStorage
var updateListenerTask: Task<Void, Never>? = nil var updateListenerTask: Task<Void, Never>? = nil
fileprivate let _freeTournaments: Int = 3 let freeTournaments: Int = 3
override init() { override init() {
@ -34,6 +34,7 @@ import LeStorage
} catch { } catch {
Logger.error(error) Logger.error(error)
} }
Logger.log("plan = \(String(describing: currentBestPurchase?.productId))")
} }
NotificationCenter.default.addObserver(self, selector: #selector(collectionDidLoad), name: NSNotification.Name.CollectionDidLoad, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(collectionDidLoad), name: NSNotification.Name.CollectionDidLoad, object: nil)
@ -263,8 +264,8 @@ import LeStorage
return TournamentPayment.unlimited return TournamentPayment.unlimited
case .fivePerMonth: case .fivePerMonth:
if let purchaseDate = self.currentBestPurchase?.purchaseDate { if let purchaseDate = self.currentBestPurchase?.purchaseDate {
let tournaments = DataStore.shared.tournaments.filter { $0.creationDate > purchaseDate && $0.payment == .subscriptionUnit && $0.isCanceled == false } let count = DataStore.shared.subscriptionUnitlyPayedTournaments(after: purchaseDate)
if tournaments.count < StoreItem.five { if count < StoreItem.five {
return TournamentPayment.subscriptionUnit return TournamentPayment.subscriptionUnit
} }
} }
@ -277,7 +278,7 @@ import LeStorage
fileprivate func _paymentWithoutSubscription() -> TournamentPayment? { fileprivate func _paymentWithoutSubscription() -> TournamentPayment? {
let freelyPayed: Int = DataStore.shared.tournaments.filter { $0.payment == .free && $0.isCanceled == false }.count let freelyPayed: Int = DataStore.shared.tournaments.filter { $0.payment == .free && $0.isCanceled == false }.count
if freelyPayed < self._freeTournaments { if freelyPayed < self.freeTournaments {
return TournamentPayment.free return TournamentPayment.free
} }
let tournamentCreditCount: Int = self._purchasedTournamentCount() let tournamentCreditCount: Int = self._purchasedTournamentCount()
@ -291,12 +292,14 @@ import LeStorage
var remainingTournaments: Int { var remainingTournaments: Int {
let unitlyPayed = DataStore.shared.tournaments.filter { $0.payment == TournamentPayment.unit }.count let unitlyPayed = DataStore.shared.tournaments.filter { $0.payment == TournamentPayment.unit }.count
let tournamentCreditCount = self._purchasedTournamentCount() let tournamentCreditCount = self._purchasedTournamentCount()
// let notDeletedTournamentCount = DataStore.shared.tournaments.filter { $0.isDeleted == false }.count
// Logger.log("unitlyPayed = \(unitlyPayed), purchased = \(tournamentCreditCount) ")
return tournamentCreditCount - unitlyPayed return tournamentCreditCount - unitlyPayed
} }
var remainingFreeTournaments: Int {
let freelyPayed = DataStore.shared.tournaments.filter { $0.payment == TournamentPayment.free }.count
return self.freeTournaments - freelyPayed
}
func disconnect() { func disconnect() {
let purchases = DataStore.shared.purchases let purchases = DataStore.shared.purchases
purchases.reset() purchases.reset()
@ -309,6 +312,8 @@ struct PurchaseRow: Identifiable {
var name: String var name: String
var item: StoreItem var item: StoreItem
var quantity: Int? var quantity: Int?
var expirationDate: Date?
var remainingCount: Int? = nil
} }
fileprivate extension StoreKit.Transaction { fileprivate extension StoreKit.Transaction {

@ -0,0 +1,99 @@
//
// PaymentStatusView.swift
// PadelClub
//
// Created by Laurent Morvillier on 29/04/2025.
//
import SwiftUI
import TipKit
struct ImageInfoView: View {
@State var systemImage: String = ""
@State var text: String = ""
@State var textColor: Color = .black
@State var backgroundColor: Color = .blue.opacity(0.2)
@State var textOnTap: String? = nil
@State var showPopover: Bool = false
var tip: (any Tip)? = nil
var body: some View {
Group {
if #available(iOS 18.4, *) {
HStack {
Image(systemName: self.systemImage)
.font(.title)
.foregroundStyle(.white)
Text(self.text)
.foregroundStyle(self.textColor)
.fontWeight(.semibold)
.frame(maxWidth: .infinity)
}.popoverTip(self.tip)
} else {
HStack {
Image(systemName: self.systemImage)
.font(.title)
.foregroundStyle(.white)
Text(self.text)
.fontWeight(.semibold)
.frame(maxWidth: .infinity)
}.onTapGesture {
if self.tip != nil {
self.showPopover = true
}
}
}
}
.alert("Message", isPresented: self.$showPopover, actions: { }, message: {
if let tip {
tip.title
} else {
Text("")
}
})
.frame(maxWidth: .infinity)
.padding()
.background(self.backgroundColor)
.listRowInsets(EdgeInsets()) // Remove default insets
}
}
struct PaymentStatusView: View {
@State var payment: TournamentPayment? = .free
var body: some View {
Group {
switch self.payment {
case .free:
let remaining = Guard.main.remainingFreeTournaments
let end = remaining > 1 ? "s" : ""
let text = "Tournoi offert (\(remaining) restant\(end))"
ImageInfoView(systemImage: "gift.fill", text: text, tip: FreeTournamentTip())
// TipView(FreeTournamentTip()).tipStyle(tint: nil, background: .blue.opacity(0.2))
case nil:
ImageInfoView(systemImage: "exclamationmark.bubble.fill", text: "Veuillez souscrire à une offre pour convoquer ou entrer un résultat", textColor: .white, backgroundColor: .logoRed, tip: NoPaymentTip())
// TipView(NoPaymentTip()).tipStyle(tint: nil, background: .logoRed)
default:
EmptyView()
}
}.onAppear {
// self.payment = nil
self.payment = Guard.main.paymentForNewTournament()
}
}
}
#Preview {
List {
PaymentStatusView()
}
}

@ -55,7 +55,12 @@ class PurchaseManager: ObservableObject {
if let userPurchase = userPurchases.first(where: { $0.productID == subscription.rawValue }), let product = self._products.first(where: { $0.id == subscription.rawValue }) { if let userPurchase = userPurchases.first(where: { $0.productID == subscription.rawValue }), let product = self._products.first(where: { $0.id == subscription.rawValue }) {
rows.append(PurchaseRow(id: userPurchase.originalID, name: product.displayName, item: subscription)) var remainingCount: Int? = nil
if subscription == .fivePerMonth {
remainingCount = StoreItem.five - DataStore.shared.subscriptionUnitlyPayedTournaments(after: userPurchase.purchaseDate)
}
rows.append(PurchaseRow(id: userPurchase.originalID, name: product.displayName, item: subscription, expirationDate: userPurchase.expirationDate, remainingCount: remainingCount))
} }
} }
@ -106,16 +111,27 @@ struct PurchaseView: View {
HStack { HStack {
Image(systemName: self.purchaseRow.item.systemImage) Image(systemName: self.purchaseRow.item.systemImage)
.foregroundColor(.accentColor).font(.title2) .foregroundColor(.accentColor).font(.title2)
VStack(alignment: .leading) {
Text(self.purchaseRow.name) Text(self.purchaseRow.name)
if let expirationDate = purchaseRow.expirationDate {
Text("Expire le \(expirationDate.dateFormatted)").font(.footnote)
.foregroundStyle(.gray)
}
}
Spacer() Spacer()
if let _ = purchaseRow.quantity { if let _ = purchaseRow.quantity {
let remaining = Guard.main.remainingTournaments let remaining = Guard.main.remainingTournaments
Text("\(remaining)") Text(remaining.formatted())
}
if let count = purchaseRow.remainingCount {
Text(count.formatted())
} }
} }
} }
} }
//#Preview { #Preview {
// PurchaseListView() List {
//} PurchaseView(purchaseRow: PurchaseRow(id: 0, name: "test", item: .fivePerMonth, expirationDate: Date(), remainingCount: 4))
}
}

@ -33,7 +33,7 @@ struct SubscriptionInfoView: View {
struct FreeTournamentTip: Tip { struct FreeTournamentTip: Tip {
var title: Text { var title: Text {
return Text("Nous vous offrons vos 3 premiers tournois ! Convoquez les équipes, créez les poules, le tableau comme vous le souhaitez. \nEnregistrez les résultats de chaque équipes et diffusez les scores en temps réel sur les écrans de votre club !") return Text("Nous vous offrons vos 3 premiers tournois ! Convoquez les équipes, créez les poules, le tableau comme vous le souhaitez. \nEnregistrez les résultats de chaque équipes et diffusez les scores en temps réel sur les écrans de votre club !\n\n Votre tournoi est décompté lorsque vous convoquez ou que vous rentrez un résultat.")
} }
var image: Image? { var image: Image? {

@ -51,7 +51,8 @@ struct TournamentView: View {
VStack(spacing: 0.0) { VStack(spacing: 0.0) {
List { List {
if tournament.state() != .finished && tournament.payment == nil { if tournament.state() != .finished && tournament.payment == nil {
SubscriptionInfoView() PaymentStatusView()
// SubscriptionInfoView()
} }
switch tournament.state() { switch tournament.state() {

Loading…
Cancel
Save