diff --git a/Moonlight/App/Extensions/Attributes/SelectorAttribute.cs b/Moonlight/App/Extensions/Attributes/SelectorAttribute.cs new file mode 100644 index 0000000..308bfa8 --- /dev/null +++ b/Moonlight/App/Extensions/Attributes/SelectorAttribute.cs @@ -0,0 +1,8 @@ +namespace Moonlight.App.Extensions.Attributes; + +public class SelectorAttribute : Attribute +{ + public string SelectorProp { get; set; } = ""; + public string DisplayProp { get; set; } = ""; + public bool UseDropdown { get; set; } = false; +} \ No newline at end of file diff --git a/Moonlight/App/Helpers/ComponentHelper.cs b/Moonlight/App/Helpers/ComponentHelper.cs new file mode 100644 index 0000000..4587164 --- /dev/null +++ b/Moonlight/App/Helpers/ComponentHelper.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Components; + +namespace Moonlight.App.Helpers; + +public static class ComponentHelper +{ + public static RenderFragment FromType(Type type) => builder => + { + builder.OpenComponent(0, type); + builder.CloseComponent(); + }; +} \ No newline at end of file diff --git a/Moonlight/App/Helpers/PropBinder.cs b/Moonlight/App/Helpers/PropBinder.cs new file mode 100644 index 0000000..52c4131 --- /dev/null +++ b/Moonlight/App/Helpers/PropBinder.cs @@ -0,0 +1,57 @@ +using System.Reflection; + +namespace Moonlight.App.Helpers; + +public class PropBinder +{ + private PropertyInfo PropertyInfo; + private object DataObject; + + public PropBinder(PropertyInfo propertyInfo, object dataObject) + { + PropertyInfo = propertyInfo; + DataObject = dataObject; + } + + public string StringValue + { + get => (string)PropertyInfo.GetValue(DataObject)!; + set => PropertyInfo.SetValue(DataObject, value); + } + + public int IntValue + { + get => (int)PropertyInfo.GetValue(DataObject)!; + set => PropertyInfo.SetValue(DataObject, value); + } + + public long LongValue + { + get => (long)PropertyInfo.GetValue(DataObject)!; + set => PropertyInfo.SetValue(DataObject, value); + } + + public bool BoolValue + { + get => (bool)PropertyInfo.GetValue(DataObject)!; + set => PropertyInfo.SetValue(DataObject, value); + } + + public DateTime DateTimeValue + { + get => (DateTime)PropertyInfo.GetValue(DataObject)!; + set => PropertyInfo.SetValue(DataObject, value); + } + + public T Class + { + get => (T)PropertyInfo.GetValue(DataObject)!; + set => PropertyInfo.SetValue(DataObject, value); + } + + public double DoubleValue + { + get => (double)PropertyInfo.GetValue(DataObject)!; + set => PropertyInfo.SetValue(DataObject, value); + } +} \ No newline at end of file diff --git a/Moonlight/App/Models/Forms/Store/AddCouponForm.cs b/Moonlight/App/Models/Forms/Store/AddCouponForm.cs new file mode 100644 index 0000000..4242e76 --- /dev/null +++ b/Moonlight/App/Models/Forms/Store/AddCouponForm.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace Moonlight.App.Models.Forms.Store; + +public class AddCouponForm +{ + [MinLength(5, ErrorMessage = "The code needs to be longer than 4")] + [MaxLength(15, ErrorMessage = "The code should not be longer than 15 characters")] + public string Code { get; set; } = ""; + + [Range(1, 99, ErrorMessage = "The percent needs to be between 1 and 99")] + public int Percent { get; set; } + + [Range(0, int.MaxValue, ErrorMessage = "The amount needs to be equals or greater than 0")] + public int Amount { get; set; } +} \ No newline at end of file diff --git a/Moonlight/App/Models/Forms/Store/AddProductForm.cs b/Moonlight/App/Models/Forms/Store/AddProductForm.cs index a2338eb..5006dde 100644 --- a/Moonlight/App/Models/Forms/Store/AddProductForm.cs +++ b/Moonlight/App/Models/Forms/Store/AddProductForm.cs @@ -1,15 +1,19 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; using Moonlight.App.Database.Entities.Store; using Moonlight.App.Database.Enums; +using Moonlight.App.Extensions.Attributes; namespace Moonlight.App.Models.Forms.Store; public class AddProductForm { [Required(ErrorMessage = "You need to specify a category")] + [Selector(DisplayProp = "Name", SelectorProp = "Name", UseDropdown = true)] public Category Category { get; set; } [Required(ErrorMessage = "You need to specify a name")] + [Description("Teeeeeeeeeeeeeeeeeeeeeeeeeest")] public string Name { get; set; } = ""; [Required(ErrorMessage = "You need to specify a description")] @@ -19,16 +23,16 @@ public class AddProductForm [RegularExpression("^[a-z0-9-]+$", ErrorMessage = "You need to enter a valid slug only containing lowercase characters and numbers")] public string Slug { get; set; } = ""; - [Range(0, double.MaxValue, ErrorMessage = "The price needs to be equals or above 0")] + [Range(0, double.MaxValue, ErrorMessage = "The price needs to be equals or greater than 0")] public double Price { get; set; } - [Range(0, double.MaxValue, ErrorMessage = "The stock needs to be equals or above 0")] + [Range(0, double.MaxValue, ErrorMessage = "The stock needs to be equals or greater than 0")] public int Stock { get; set; } - [Range(0, double.MaxValue, ErrorMessage = "The max per user amount needs to be equals or above 0")] + [Range(0, double.MaxValue, ErrorMessage = "The max per user amount needs to be equals or greater than 0")] public int MaxPerUser { get; set; } - [Range(0, double.MaxValue, ErrorMessage = "The duration needs to be equals or above 0")] + [Range(0, double.MaxValue, ErrorMessage = "The duration needs to be equals or greater than 0")] public int Duration { get; set; } public ServiceType Type { get; set; } diff --git a/Moonlight/Shared/Components/Forms/AutoCrud.razor b/Moonlight/Shared/Components/Forms/AutoCrud.razor new file mode 100644 index 0000000..6ab4b54 --- /dev/null +++ b/Moonlight/Shared/Components/Forms/AutoCrud.razor @@ -0,0 +1,67 @@ +@using BlazorTable +@using Moonlight.App.Repositories + +@typeparam TItem where TItem : class + +@inject Repository ItemRepository + +
+
+

@(Title)

+
+ +
+
+
+ + + @ChildContent +
+
+
+
+ +@code +{ + [Parameter] + public string Title { get; set; } = ""; + + [Parameter] + public Type CreateForm { get; set; } + + [Parameter] + public Type UpdateForm { get; set; } + + [Parameter] + public Func, TItem[]> Load { get; set; } + + [Parameter] + public RenderFragment ChildContent { get; set; } + + private TItem[] Items; + + protected override void OnInitialized() + { + if (CreateForm == null) + throw new ArgumentNullException(nameof(CreateForm)); + + if (UpdateForm == null) + throw new ArgumentNullException(nameof(UpdateForm)); + + if (Load == null) + throw new ArgumentNullException(nameof(Load)); + } + + private Task LoadItems(LazyLoader _) + { + Items = Load.Invoke(ItemRepository); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/Moonlight/Shared/Components/Forms/AutoForm.razor b/Moonlight/Shared/Components/Forms/AutoForm.razor new file mode 100644 index 0000000..553c3d6 --- /dev/null +++ b/Moonlight/Shared/Components/Forms/AutoForm.razor @@ -0,0 +1,27 @@ +@using Moonlight.App.Extensions + +@typeparam TForm + +@foreach (var prop in typeof(TForm).GetProperties()) +{ +
+ + + @{ + var typeToCreate = typeof(AutoProperty<>).MakeGenericType(prop.PropertyType); + } + + @ComponentHelper.FromType(typeToCreate) + + +
+} + +@code +{ + [Parameter] + public TForm Model { get; set; } + + [Parameter] + public int Columns { get; set; } = 6; +} \ No newline at end of file diff --git a/Moonlight/Shared/Components/Forms/AutoProperty.razor b/Moonlight/Shared/Components/Forms/AutoProperty.razor new file mode 100644 index 0000000..1bc407b --- /dev/null +++ b/Moonlight/Shared/Components/Forms/AutoProperty.razor @@ -0,0 +1,151 @@ +@using System.Reflection +@using System.ComponentModel +@using Microsoft.AspNetCore.Components.Forms +@using Moonlight.App.Extensions.Attributes +@using Moonlight.App.Repositories + +@typeparam TProp +@inject IServiceProvider ServiceProvider + + + +@* Description using attribute *@ + +@{ + var attrs = Property.GetCustomAttributes(true); + + var descAttr = attrs + .FirstOrDefault(x => x.GetType() == typeof(DescriptionAttribute)); +} + +@if (descAttr != null) +{ + var attribute = descAttr as DescriptionAttribute; + +
+ @(attribute!.Description) +
+} + +@* Actual value binding *@ + +
+ @if (Property.PropertyType == typeof(string)) + { +
+ +
+ } + else if (Property.PropertyType == typeof(int)) + { + + } + else if (Property.PropertyType == typeof(double)) + { + + } + else if (Property.PropertyType == typeof(long)) + { + + } + else if (Property.PropertyType == typeof(bool)) + { +
+ +
+ } + else if (Property.PropertyType == typeof(DateTime)) + { + + } + else if (Property.PropertyType == typeof(decimal)) + { + + } + else if (Property.PropertyType.IsEnum) + { + + } + else if (Property.PropertyType.IsClass) + { + var attribute = Property.GetCustomAttributes(true) + .FirstOrDefault(x => x.GetType() == typeof(SelectorAttribute)) as SelectorAttribute; + + if (attribute != null) + { + if (attribute.UseDropdown) + { + var displayFunc = new Func(x => + { + var prop = typeof(TProp).GetProperties().First(x => x.Name == attribute.DisplayProp); + return prop.GetValue(x) as string ?? "N/A"; + }); + + var searchFunc = new Func(x => + { + var prop = typeof(TProp).GetProperties().First(x => x.Name == attribute.SelectorProp); + return prop.GetValue(x) as string ?? "N/A"; + }); + + + } + else + { + var displayFunc = new Func(x => + { + var prop = typeof(TProp).GetProperties().First(x => x.Name == attribute.DisplayProp); + return prop.GetValue(x) as string ?? "N/A"; + }); + + + } + } + } +
+ +@code +{ + [CascadingParameter(Name = "Data")] + public object Data { get; set; } + + [CascadingParameter(Name = "Property")] + public PropertyInfo Property { get; set; } + + private PropBinder Binder; + private TProp[] Items = Array.Empty(); + + protected override void OnInitialized() + { + Binder = new(Property, Data); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + if (Property.GetCustomAttributes(true).Any(x => x.GetType() == typeof(SelectorAttribute))) + { + var typeToGetByDi = typeof(Repository<>).MakeGenericType(typeof(TProp)); + var repo = ServiceProvider.GetRequiredService(typeToGetByDi); + var dbSet = repo.GetType().GetMethods().First(x => x.Name == "Get").Invoke(repo, null) as IEnumerable; + Items = dbSet!.ToArray(); + + await InvokeAsync(StateHasChanged); + } + } + } +} \ No newline at end of file diff --git a/Moonlight/Shared/Components/Forms/SmartDropdown.razor b/Moonlight/Shared/Components/Forms/SmartDropdown.razor new file mode 100644 index 0000000..4e014a8 --- /dev/null +++ b/Moonlight/Shared/Components/Forms/SmartDropdown.razor @@ -0,0 +1,141 @@ +@using Microsoft.AspNetCore.Components.Forms + +@typeparam T +@inherits InputBase + + + +@code { + + [Parameter] + public IEnumerable Items { get; set; } + + [Parameter] + public Func DisplayFunc { get; set; } + + [Parameter] + public Func SearchProp { get; set; } + + [Parameter] + public Func? OnSelected { get; set; } + + [Parameter] + public string Placeholder { get; set; } = "Search..."; + + private string SearchTerm + { + get => searchTerm; + set + { + searchTerm = value; + FilteredItems = Items.OrderByDescending(x => Matches(SearchProp(x), searchTerm)).Take(30).ToList(); + } + } + + private string searchTerm = ""; + + private List FilteredItems = new(); + + private async void SelectItem(T item) + { + CurrentValue = item; + SearchTerm = ""; + FilteredItems.Clear(); + + if (OnSelected != null) + await OnSelected.Invoke(); + } + + protected override bool TryParseValueFromString(string? value, out T result, out string? validationErrorMessage) + { + // Check if the value is null or empty + if (string.IsNullOrEmpty(value)) + { + result = default(T)!; + validationErrorMessage = "Value cannot be null or empty"; + return false; + } + + // Try to find an item that matches the search term + var item = FilteredItems.OrderByDescending(x => Matches(SearchProp(x), value)).FirstOrDefault(); + if (item != null) + { + result = item; + validationErrorMessage = null; + return true; + } + else + { + result = default(T)!; + validationErrorMessage = $"No item found for search term '{value}'"; + return false; + } + } + + private float Matches(string input, string search) + { + if (string.IsNullOrEmpty(input) || string.IsNullOrEmpty(search)) + return 0; + + var cleanedSearch = search + .ToLower() + .Replace(" ", "") + .Replace("-", ""); + + var cleanedInput = input + .ToLower() + .Replace(" ", "") + .Replace("-", ""); + + if (cleanedInput == cleanedSearch) + return 10000; + + float matches = 0; + + int i = 0; + foreach (var c in cleanedInput) + { + if (cleanedSearch.Length > i) + { + if (c == cleanedSearch[i]) + matches++; + else + matches--; + } + + i++; + } + + matches = matches / searchTerm.Length; + + return matches; + } +} \ No newline at end of file diff --git a/Moonlight/Shared/Components/Modals/Store/AddCouponModal.razor b/Moonlight/Shared/Components/Modals/Store/AddCouponModal.razor new file mode 100644 index 0000000..f1c0ca3 --- /dev/null +++ b/Moonlight/Shared/Components/Modals/Store/AddCouponModal.razor @@ -0,0 +1,59 @@ +@using Moonlight.App.Models.Forms.Store +@using Moonlight.App.Repositories +@using Moonlight.App.Database.Entities.Store +@using Mappy.Net + +@inject Repository CouponRepository +@inject ToastService ToastService + + + + + + + + + +@code +{ + [Parameter] + public Func OnUpdate { get; set; } + + private SmartModal Modal; + private AddCouponForm Form = new(); + + public async Task Show() + { + await Modal.Show(); + } + + private async Task OnValidSubmit() + { + var coupon = Mapper.Map(Form); + CouponRepository.Add(coupon); + + Form = new(); + await ToastService.Success("Successfully added new coupon"); + await Modal.Hide(); + await OnUpdate.Invoke(); + } +} \ No newline at end of file diff --git a/Moonlight/Shared/Views/Admin/Store/Coupons.razor b/Moonlight/Shared/Views/Admin/Store/Coupons.razor index 35549eb..27d2bb5 100644 --- a/Moonlight/Shared/Views/Admin/Store/Coupons.razor +++ b/Moonlight/Shared/Views/Admin/Store/Coupons.razor @@ -1,5 +1,70 @@ @page "/admin/store/coupons" +@using BlazorTable +@using Moonlight.App.Database.Entities.Store +@using Moonlight.App.Repositories +@using Moonlight.Shared.Components.Modals.Store + +@inject Repository CouponRepository +@inject Repository CouponUseRepository +@inject ToastService ToastService + -@*
+
+

Coupons

+
+ +
+
+
+ + + + + + + + + +
+
+
+
+ + + +@code +{ + private Coupon[] AllCoupons; + + private LazyLoader LazyLoader; + private AddCouponModal AddCouponModal; + + private Task Load(LazyLoader _) + { + AllCoupons = CouponRepository + .Get() + .ToArray(); + + return Task.CompletedTask; + } + + private async Task Remove(Coupon coupon) + { + if (CouponUseRepository.Get().Any(x => x.Coupon.Id == coupon.Id)) + throw new DisplayException("The coupon has been used so it cannot be deleted"); + + CouponRepository.Delete(coupon); + + await ToastService.Success("Successfully deleted coupon"); + await LazyLoader.Reload(); + } +}