mirror of https://github.com/abpframework/eventhub
committed by
GitHub
50 changed files with 3623 additions and 371 deletions
@ -0,0 +1,19 @@ |
|||
using System.Collections.Generic; |
|||
|
|||
namespace EventHub.Organizations; |
|||
|
|||
public class FeatureOfPlanDefinitionDto |
|||
{ |
|||
public uint? MaxAllowedEventsCountInOneYear { get; set; } |
|||
|
|||
public uint? MaxAllowedTracksCountInOneEvent { get; set; } |
|||
|
|||
public uint? MaxAllowedAttendeesCountInOneEvent { get; set; } |
|||
|
|||
public List<string> AdditionalFeatureInfos { get; set; } |
|||
|
|||
public FeatureOfPlanDefinitionDto() |
|||
{ |
|||
AdditionalFeatureInfos = new List<string>(); |
|||
} |
|||
} |
|||
@ -0,0 +1,25 @@ |
|||
namespace EventHub.Organizations; |
|||
|
|||
public class PlanInfoDefinitionDto |
|||
{ |
|||
public OrganizationPlanType PlanType { get; set; } |
|||
|
|||
public string Description { get; set; } |
|||
|
|||
public bool IsActive { get; set; } |
|||
|
|||
public decimal Price { get; set; } |
|||
|
|||
public bool IsExtendable { get; set; } |
|||
|
|||
public int? CanBeExtendedAfterHowManyMonths { get; set; } |
|||
|
|||
public int? OnePaidEnrollmentPeriodAsMonth { get; set; } |
|||
|
|||
public FeatureOfPlanDefinitionDto Feature { get; set; } |
|||
|
|||
public PlanInfoDefinitionDto() |
|||
{ |
|||
Feature = new FeatureOfPlanDefinitionDto(); |
|||
} |
|||
} |
|||
@ -0,0 +1,28 @@ |
|||
using System.Collections.Generic; |
|||
using System.ComponentModel.DataAnnotations; |
|||
using JetBrains.Annotations; |
|||
|
|||
namespace EventHub.Organizations.Plans; |
|||
|
|||
public class FeatureOfPlanDefinition |
|||
{ |
|||
[CanBeNull] |
|||
[Range(0, uint.MaxValue)] |
|||
public uint? MaxAllowedEventsCountInOneYear { get; set; } |
|||
|
|||
[CanBeNull] |
|||
[Range(0, uint.MaxValue)] |
|||
public uint? MaxAllowedTracksCountInOneEvent { get; set; } |
|||
|
|||
[CanBeNull] |
|||
[Range(0, uint.MaxValue)] |
|||
public uint? MaxAllowedAttendeesCountInOneEvent { get; set; } |
|||
|
|||
[NotNull] |
|||
public List<string> AdditionalFeatureInfos { get; set; } |
|||
|
|||
public FeatureOfPlanDefinition() |
|||
{ |
|||
AdditionalFeatureInfos = new List<string>(); |
|||
} |
|||
} |
|||
@ -0,0 +1,61 @@ |
|||
using System; |
|||
using System.Threading.Tasks; |
|||
using EventHub.Events; |
|||
using EventHub.Events.Registrations; |
|||
using Microsoft.Extensions.Options; |
|||
using Volo.Abp.DependencyInjection; |
|||
using Volo.Abp.Domain.Repositories; |
|||
using Volo.Abp.Timing; |
|||
|
|||
namespace EventHub.Organizations.Plans; |
|||
|
|||
public class PlanFeatureManager : ITransientDependency |
|||
{ |
|||
private readonly IOptions<PlanInfoOptions> _planInfoOptions; |
|||
private readonly IRepository<Organization, Guid> _organizationRepository; |
|||
private readonly IRepository<Event, Guid> _eventRepository; |
|||
private readonly IEventRegistrationRepository _eventRegistrationRepository; |
|||
private readonly IClock _clock; |
|||
public PlanFeatureManager( |
|||
IOptions<PlanInfoOptions> planInfoOptions, |
|||
IRepository<Organization, Guid> organizationRepository, |
|||
IRepository<Event, Guid> eventRepository, |
|||
IEventRegistrationRepository eventRegistrationRepository, |
|||
IClock clock) |
|||
{ |
|||
_planInfoOptions = planInfoOptions; |
|||
_organizationRepository = organizationRepository; |
|||
_eventRepository = eventRepository; |
|||
_eventRegistrationRepository = eventRegistrationRepository; |
|||
_clock = clock; |
|||
} |
|||
|
|||
public virtual async Task<bool> CanCreateNewEventAsync(Guid organizationId) |
|||
{ |
|||
var currentPlanOfOrganization = await GetCurrentPlanByOrganizationIdAsync(organizationId); |
|||
var totalEventCountByOrganizationInThisYear = await _eventRepository.CountAsync(x => x.OrganizationId == organizationId && x.CreationTime.Year == _clock.Now.Year); |
|||
return currentPlanOfOrganization.Feature.MaxAllowedEventsCountInOneYear > totalEventCountByOrganizationInThisYear; |
|||
} |
|||
|
|||
public virtual async Task<bool> CanAddNewTrackAsync(Event @event) |
|||
{ |
|||
var currentPlanOfOrganization = await GetCurrentPlanByOrganizationIdAsync(@event.OrganizationId); |
|||
|
|||
var tracksCount = @event.Tracks.Count; |
|||
return currentPlanOfOrganization.Feature.MaxAllowedTracksCountInOneEvent > tracksCount; |
|||
} |
|||
|
|||
public virtual async Task<bool> CanRegisterToEventAsync(Event @event) |
|||
{ |
|||
var currentPlanOfOrganization = await GetCurrentPlanByOrganizationIdAsync(@event.OrganizationId); |
|||
|
|||
var attendeesCount = await _eventRegistrationRepository.CountAsync(x => x.EventId == @event.Id); |
|||
return currentPlanOfOrganization.Feature.MaxAllowedAttendeesCountInOneEvent > attendeesCount; |
|||
} |
|||
|
|||
private async Task<PlanInfoDefinition> GetCurrentPlanByOrganizationIdAsync(Guid organizationId) |
|||
{ |
|||
var organization = await _organizationRepository.GetAsync(organizationId); |
|||
return _planInfoOptions.Value.GetPlanInfoByType(organization.PlanType); |
|||
} |
|||
} |
|||
@ -0,0 +1,72 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.ComponentModel.DataAnnotations; |
|||
using System.Linq; |
|||
using JetBrains.Annotations; |
|||
using Volo.Abp; |
|||
|
|||
namespace EventHub.Organizations.Plans; |
|||
|
|||
public class PlanInfoDefinition |
|||
{ |
|||
public const string PlanInfo = "PlanInfos"; |
|||
|
|||
public OrganizationPlanType PlanType { get; set; } |
|||
|
|||
public string Description { get; set; } |
|||
|
|||
public bool IsActive { get; set; } |
|||
|
|||
[Range(1.0, 100.0)] |
|||
public decimal Price { get; set; } = 1.0M; |
|||
|
|||
public bool IsExtendable { get; set; } |
|||
|
|||
[CanBeNull] |
|||
[Range(0, 12)] |
|||
public int? CanBeExtendedAfterHowManyMonths { get; set; } |
|||
|
|||
[CanBeNull] |
|||
[Range(1, 24)] |
|||
public int? OnePaidEnrollmentPeriodAsMonth { get; set; } = 12; |
|||
|
|||
public FeatureOfPlanDefinition Feature { get; set; } |
|||
|
|||
public PlanInfoDefinition() |
|||
{ |
|||
Feature = new FeatureOfPlanDefinition |
|||
{ |
|||
MaxAllowedEventsCountInOneYear = uint.MaxValue, |
|||
MaxAllowedTracksCountInOneEvent = uint.MaxValue, |
|||
MaxAllowedAttendeesCountInOneEvent = uint.MaxValue, |
|||
AdditionalFeatureInfos = new List<string>() |
|||
}; |
|||
} |
|||
|
|||
public static bool IsValid(List<PlanInfoDefinition> definitions) |
|||
{ |
|||
foreach (var definition in definitions) |
|||
{ |
|||
var isValidPlanType = Enum.IsDefined(typeof(OrganizationPlanType), definition.PlanType); |
|||
if (!isValidPlanType) |
|||
{ |
|||
return false; |
|||
} |
|||
|
|||
var isExistSamePlan = definitions.Count(x => x.Price == definition.Price && x.PlanType == definition.PlanType); |
|||
if (isExistSamePlan > 1) |
|||
{ |
|||
return false; |
|||
} |
|||
|
|||
if (definition.IsActive && definition.IsExtendable) |
|||
{ |
|||
Check.NotNull(definition.OnePaidEnrollmentPeriodAsMonth, nameof(definition.OnePaidEnrollmentPeriodAsMonth)); |
|||
Check.NotNull(definition.CanBeExtendedAfterHowManyMonths, nameof(definition.CanBeExtendedAfterHowManyMonths)); |
|||
return definition.OnePaidEnrollmentPeriodAsMonth > definition.CanBeExtendedAfterHowManyMonths; |
|||
} |
|||
} |
|||
|
|||
return true; |
|||
} |
|||
} |
|||
@ -0,0 +1,41 @@ |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
|
|||
namespace EventHub.Organizations.Plans; |
|||
|
|||
public class PlanInfoOptions |
|||
{ |
|||
public List<PlanInfoDefinition> Infos { get; } |
|||
|
|||
public PlanInfoOptions() |
|||
{ |
|||
Infos = new List<PlanInfoDefinition>(); |
|||
} |
|||
|
|||
public PlanInfoOptions AddPlanInfos(List<PlanInfoDefinition> infos) |
|||
{ |
|||
foreach (var info in infos) |
|||
{ |
|||
AddPlanInfo(info); |
|||
} |
|||
|
|||
return this; |
|||
} |
|||
|
|||
private PlanInfoOptions AddPlanInfo(PlanInfoDefinition info) |
|||
{ |
|||
Infos.AddIfNotContains(info); |
|||
|
|||
return this; |
|||
} |
|||
|
|||
public List<PlanInfoDefinition> GetPlanInfos() |
|||
{ |
|||
return Infos.Where(x => x.IsActive).ToList(); |
|||
} |
|||
|
|||
public PlanInfoDefinition GetPlanInfoByType(OrganizationPlanType type) |
|||
{ |
|||
return Infos.SingleOrDefault(x => x.IsActive && x.PlanType == type); |
|||
} |
|||
} |
|||
File diff suppressed because it is too large
@ -0,0 +1,57 @@ |
|||
using Microsoft.EntityFrameworkCore.Migrations; |
|||
|
|||
#nullable disable |
|||
|
|||
namespace EventHub.Migrations |
|||
{ |
|||
public partial class Updated_Organization : Migration |
|||
{ |
|||
protected override void Up(MigrationBuilder migrationBuilder) |
|||
{ |
|||
migrationBuilder.DropColumn( |
|||
name: "IsPremium", |
|||
table: "EhOrganizations"); |
|||
|
|||
migrationBuilder.RenameColumn( |
|||
name: "PremiumEndDate", |
|||
table: "EhOrganizations", |
|||
newName: "PaidEnrollmentEndDate"); |
|||
|
|||
migrationBuilder.RenameColumn( |
|||
name: "IsSendPremiumReminderEmail", |
|||
table: "EhOrganizations", |
|||
newName: "IsSendPaidEnrollmentReminderEmail"); |
|||
|
|||
migrationBuilder.AddColumn<byte>( |
|||
name: "PlanType", |
|||
table: "EhOrganizations", |
|||
type: "smallint", |
|||
nullable: false, |
|||
defaultValue: (byte)0); |
|||
} |
|||
|
|||
protected override void Down(MigrationBuilder migrationBuilder) |
|||
{ |
|||
migrationBuilder.DropColumn( |
|||
name: "PlanType", |
|||
table: "EhOrganizations"); |
|||
|
|||
migrationBuilder.RenameColumn( |
|||
name: "PaidEnrollmentEndDate", |
|||
table: "EhOrganizations", |
|||
newName: "PremiumEndDate"); |
|||
|
|||
migrationBuilder.RenameColumn( |
|||
name: "IsSendPaidEnrollmentReminderEmail", |
|||
table: "EhOrganizations", |
|||
newName: "IsSendPremiumReminderEmail"); |
|||
|
|||
migrationBuilder.AddColumn<bool>( |
|||
name: "IsPremium", |
|||
table: "EhOrganizations", |
|||
type: "boolean", |
|||
nullable: false, |
|||
defaultValue: false); |
|||
} |
|||
} |
|||
} |
|||
@ -1,56 +1,67 @@ |
|||
@using EventHub.Web.PaymentRequests |
|||
@using Microsoft.Extensions.Options |
|||
@using EventHub.Organizations |
|||
@model EventHub.Organizations.OrganizationProfileDto |
|||
@inject IOptionsSnapshot<PremiumPlanInfoOptions> PremiumPlanInfoOptionsSnapshot |
|||
@inject IOrganizationAppService OrganizationAppService |
|||
|
|||
<abp-container class="mt-4"> |
|||
<abp-row> |
|||
@if (Model.IsPremium && PremiumPlanInfoOptionsSnapshot.Value.IsActive && PremiumPlanInfoOptionsSnapshot.Value.IsExtendable && Model.PremiumEndDate!.Value.IsBetween(DateTime.Now, DateTime.Now.AddMonths(PremiumPlanInfoOptionsSnapshot.Value.OnePremiumPeriodAsMonth - PremiumPlanInfoOptionsSnapshot.Value.CanBeExtendedAfterHowManyMonths))) |
|||
{ |
|||
<span class="mb-2 ms-5 text-center strong">Premium end date: @Model.PremiumEndDate!.Value.ToShortDateString()</span> |
|||
} |
|||
<abp-column> |
|||
<a href="/organization/edit/@Model.Name" class="btn btn-success text-white">Edit</a> |
|||
</abp-column> |
|||
<abp-column size="_9"> |
|||
@if (PremiumPlanInfoOptionsSnapshot.Value.IsActive) |
|||
@{ |
|||
PlanInfoDefinitionDto currentPlanInfo = null; |
|||
if (Model is not null) |
|||
{ |
|||
var planInfos = await OrganizationAppService.GetPlanInfosAsync(); |
|||
currentPlanInfo = planInfos.SingleOrDefault(x => x.PlanType == Model.PlanType); |
|||
} |
|||
} |
|||
|
|||
@if (Model is not null && currentPlanInfo is not null) |
|||
{ |
|||
<abp-container class="mt-4"> |
|||
<abp-row> |
|||
@if (Model.PlanType != OrganizationPlanType.Free && currentPlanInfo.IsActive && currentPlanInfo.IsExtendable && Model.PaidEnrollmentEndDate!.Value.IsBetween(DateTime.Now, DateTime.Now.AddMonths(currentPlanInfo.OnePaidEnrollmentPeriodAsMonth!.Value - currentPlanInfo.CanBeExtendedAfterHowManyMonths!.Value))) |
|||
{ |
|||
@if (Model.IsPremium) |
|||
<span class="mb-2 ms-5 text-center strong">@Model.PlanType end date: @Model.PaidEnrollmentEndDate!.Value.ToShortDateString()</span> |
|||
} |
|||
<abp-column> |
|||
<a href="/organization/edit/@Model.Name" class="btn btn-success text-white">Edit</a> |
|||
</abp-column> |
|||
<abp-column size="_9"> |
|||
@if (currentPlanInfo.IsActive) |
|||
{ |
|||
@if (PremiumPlanInfoOptionsSnapshot.Value.IsExtendable && Model.PremiumEndDate!.Value.IsBetween(DateTime.Now, DateTime.Now.AddMonths(PremiumPlanInfoOptionsSnapshot.Value.OnePremiumPeriodAsMonth - PremiumPlanInfoOptionsSnapshot.Value.CanBeExtendedAfterHowManyMonths))) |
|||
@if (Model.PlanType != OrganizationPlanType.Free) |
|||
{ |
|||
<div class="d-grid gap-2"> |
|||
<a href="@Url.Page("/Pricing", new { organizationName = Model.Name })" class="btn btn-info"> |
|||
<i class="fas fa-crown me-2"></i> Extend Now |
|||
</a> |
|||
</div> |
|||
@if (currentPlanInfo.IsExtendable && Model.PaidEnrollmentEndDate!.Value.IsBetween(DateTime.Now, DateTime.Now.AddMonths(currentPlanInfo.OnePaidEnrollmentPeriodAsMonth!.Value - currentPlanInfo.CanBeExtendedAfterHowManyMonths!.Value))) |
|||
{ |
|||
<div class="d-grid gap-2"> |
|||
<a href="@Url.Page("/Pricing", new { organizationName = Model.Name })" class="btn btn-info"> |
|||
<i class="fas fa-crown me-2"></i> Extend Now |
|||
</a> |
|||
</div> |
|||
} |
|||
else |
|||
{ |
|||
<div class="d-grid gap-2"> |
|||
<button class="btn btn-info"> |
|||
<span class="mt-2">@Model.PlanType end date: @Model.PaidEnrollmentEndDate!.Value.ToShortDateString()</span> |
|||
</button> |
|||
</div> |
|||
} |
|||
} |
|||
else |
|||
{ |
|||
<div class="d-grid gap-2"> |
|||
<button class="btn btn-info"> |
|||
<span class="mt-2">Premium end date: @Model.PremiumEndDate!.Value.ToShortDateString()</span> |
|||
</button> |
|||
<a href="@Url.Page("/Pricing", new { organizationName = Model.Name })" class="btn btn-info"> |
|||
<i class="fas fa-crown me-2"></i> Upgrade your organization |
|||
</a> |
|||
</div> |
|||
} |
|||
} |
|||
else |
|||
{ |
|||
<div class="d-grid gap-2"> |
|||
<a href="@Url.Page("/Pricing", new { organizationName = Model.Name })" class="btn btn-info"> |
|||
<i class="fas fa-crown me-2"></i> Upgrade To @PremiumPlanInfoOptionsSnapshot.Value.PlanType |
|||
</a> |
|||
</div> |
|||
<abp-card class="text-center"> |
|||
<abp-card-body> |
|||
<span>@Model.PlanType end date: @Model.PaidEnrollmentEndDate!.Value.ToShortDateString()</span> |
|||
</abp-card-body> |
|||
</abp-card> |
|||
} |
|||
} |
|||
else |
|||
{ |
|||
<abp-card class="text-center"> |
|||
<abp-card-body> |
|||
<span>Premium end date: @Model.PremiumEndDate!.Value.ToShortDateString()</span> |
|||
</abp-card-body> |
|||
</abp-card> |
|||
} |
|||
</abp-column> |
|||
</abp-row> |
|||
</abp-container> |
|||
</abp-column> |
|||
</abp-row> |
|||
</abp-container> |
|||
} |
|||
|
|||
@ -0,0 +1,74 @@ |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using System.Text; |
|||
using EventHub.Organizations; |
|||
using Volo.Abp; |
|||
|
|||
namespace EventHub.Web.Pages.Organizations; |
|||
|
|||
public static class PlanInfoHelper |
|||
{ |
|||
public static PlanInfoDefinitionDto GetPlan(OrganizationPlanType planType, List<PlanInfoDefinitionDto> plans) |
|||
{ |
|||
return plans.Single(x => x.PlanType == planType); |
|||
} |
|||
|
|||
public static string GetProductId(PlanInfoDefinitionDto premiumPlan, bool isExtend) |
|||
{ |
|||
var productIdStringBuilder = new StringBuilder(OrganizationConsts.ProductNamePrefix); |
|||
productIdStringBuilder.Append("-"); |
|||
productIdStringBuilder.Append(premiumPlan.PlanType.ToString()); |
|||
productIdStringBuilder.Append("-"); |
|||
|
|||
if (isExtend) |
|||
{ |
|||
if (!premiumPlan.IsExtendable) |
|||
{ |
|||
throw new BusinessException(); |
|||
} |
|||
|
|||
|
|||
productIdStringBuilder.Append("Extend"); |
|||
} |
|||
else |
|||
{ |
|||
productIdStringBuilder.Append("Upgrade"); |
|||
} |
|||
|
|||
return productIdStringBuilder.ToString(); |
|||
} |
|||
|
|||
public static string GetProductName(PlanInfoDefinitionDto premiumPlan, string organizationName, bool isExtend) |
|||
{ |
|||
var productNameStringBuilder = new StringBuilder(OrganizationConsts.ProductNamePrefix); |
|||
productNameStringBuilder.Append(" "); |
|||
productNameStringBuilder.Append(premiumPlan.PlanType.ToString()); |
|||
productNameStringBuilder.Append(" "); |
|||
|
|||
if (isExtend) |
|||
{ |
|||
if (!premiumPlan.IsExtendable) |
|||
{ |
|||
throw new BusinessException(); |
|||
} |
|||
|
|||
|
|||
productNameStringBuilder.Append("Extend"); |
|||
} |
|||
else |
|||
{ |
|||
productNameStringBuilder.Append("Upgrade"); |
|||
} |
|||
|
|||
productNameStringBuilder.Append(" | "); |
|||
productNameStringBuilder.Append(organizationName); |
|||
|
|||
return productNameStringBuilder.ToString(); |
|||
} |
|||
|
|||
public static string GetFeatureInfoByCount(uint? count) |
|||
{ |
|||
count ??= uint.MaxValue; |
|||
return count == uint.MaxValue ? "Unlimited" : count.ToString(); |
|||
} |
|||
} |
|||
@ -1,85 +0,0 @@ |
|||
using System.ComponentModel; |
|||
using System.ComponentModel.DataAnnotations; |
|||
using System.Text; |
|||
using EventHub.Organizations; |
|||
using Volo.Abp; |
|||
|
|||
namespace EventHub.Web.PaymentRequests |
|||
{ |
|||
public class PremiumPlanInfoOptions |
|||
{ |
|||
private const string ProductNamePrefix = "EventHub"; |
|||
|
|||
public const string OrganizationPlanInfo = "PlanInfo:Premium"; |
|||
|
|||
public OrganizationPlanType PlanType { get; private set; } = OrganizationPlanType.Premium; |
|||
|
|||
[DefaultValue(false)] |
|||
public bool IsActive { get; set; } |
|||
|
|||
[Range(1.0, 100.0, ErrorMessage = "Value for {0} must be between {1} and {2}.")] |
|||
public decimal Price { get; set; } = 1.0M; |
|||
|
|||
[DefaultValue(false)] |
|||
public bool IsExtendable { get; set; } |
|||
|
|||
[Range(0, 12, ErrorMessage = "Value for {0} must be between {1} and {2}.")] |
|||
public int CanBeExtendedAfterHowManyMonths { get; set; } |
|||
|
|||
[Range(1, 24, ErrorMessage = "Value for {0} must be between {1} and {2}.")] |
|||
public int OnePremiumPeriodAsMonth { get; set; } = 12; |
|||
|
|||
public static string GetProductId(PremiumPlanInfoOptions premiumPlan, bool isExtend) |
|||
{ |
|||
var productIdStringBuilder = new StringBuilder(ProductNamePrefix); |
|||
productIdStringBuilder.Append("-"); |
|||
productIdStringBuilder.Append(premiumPlan.PlanType.ToString()); |
|||
productIdStringBuilder.Append("-"); |
|||
|
|||
if (isExtend) |
|||
{ |
|||
if (!premiumPlan.IsExtendable) |
|||
{ |
|||
throw new BusinessException(); |
|||
} |
|||
|
|||
|
|||
productIdStringBuilder.Append("Extend"); |
|||
} |
|||
else |
|||
{ |
|||
productIdStringBuilder.Append("Upgrade"); |
|||
} |
|||
|
|||
return productIdStringBuilder.ToString(); |
|||
} |
|||
|
|||
public static string GetProductName(PremiumPlanInfoOptions premiumPlan, string organizationName, bool isExtend) |
|||
{ |
|||
var productNameStringBuilder = new StringBuilder(ProductNamePrefix); |
|||
productNameStringBuilder.Append(" "); |
|||
productNameStringBuilder.Append(premiumPlan.PlanType.ToString()); |
|||
productNameStringBuilder.Append(" "); |
|||
|
|||
if (isExtend) |
|||
{ |
|||
if (!premiumPlan.IsExtendable) |
|||
{ |
|||
throw new BusinessException(); |
|||
} |
|||
|
|||
|
|||
productNameStringBuilder.Append("Extend"); |
|||
} |
|||
else |
|||
{ |
|||
productNameStringBuilder.Append("Upgrade"); |
|||
} |
|||
|
|||
productNameStringBuilder.Append(" | "); |
|||
productNameStringBuilder.Append(organizationName); |
|||
|
|||
return productNameStringBuilder.ToString(); |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue