Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
be2cd4d
Change `MailService` to allow sending same mail to many recipients
kTrzcinskii May 25, 2025
bbdab14
Implement sending messages by organizer
kTrzcinskii May 25, 2025
50deeca
Merge pull request #129 from Resellio/feat/organizer-send-messages
staszkiet May 26, 2025
e0854a4
Fix filtering by name and description
kTrzcinskii Jun 8, 2025
86acaa4
Add organizer details
staszkiet Jun 8, 2025
91002db
Change sold ticket count
staszkiet Jun 8, 2025
5fbf094
Get rid of result in calls without errors
staszkiet Jun 8, 2025
01f0970
Merge pull request #134 from Resellio/feat/detailsfororganizer
staszkiet Jun 8, 2025
7eb9e32
Merge pull request #133 from Resellio/fix/event-filter-by-name-or-des…
kubapoke Jun 10, 2025
9cd5212
added default name on ticket setting
kubapoke Jun 11, 2025
d1482f0
Merge branch 'develop' of https://github.com/Resellio/api
staszkiet Jun 12, 2025
9455a96
Change organizer event details dto
staszkiet Jun 12, 2025
e033060
added the (not implemented) methods for both of the new endpoints
kubapoke Jun 12, 2025
a6e2f76
added (non implemented) resell ticket methods in ShoppingCartsControl…
kubapoke Jun 12, 2025
3d77124
Add blob
staszkiet Jun 13, 2025
12e20e7
Merge pull request #135 from Resellio/fix/eventdetailsdto
staszkiet Jun 13, 2025
c450a7e
Merge branch 'feat/blob' of https://github.com/Resellio/api into feat…
staszkiet Jun 13, 2025
8c3de2e
Merge branch 'develop' into feat/shopping-cart-for-resells
kubapoke Jun 13, 2025
7c6c42f
fixed GetTicketsForResellAsync to return correct prices
kubapoke Jun 13, 2025
43f874a
finished adding resell tickets to cart
kubapoke Jun 13, 2025
f4662ed
added the ability to remove resell tickets from cart
kubapoke Jun 13, 2025
fcb5fbf
appsettings example
staszkiet Jun 13, 2025
c3c28c9
Working blob
staszkiet Jun 13, 2025
4aa6268
Return parameter 'Used' in ticket details
kasrow12 Jun 13, 2025
80c74c1
added correctness checks to AddResellTicketToCartAsync
kubapoke Jun 14, 2025
78098e6
added checkout logic for resell tickets
kubapoke Jun 14, 2025
1a34144
added some missing logic from the end of resell process
kubapoke Jun 14, 2025
3b14163
Merge pull request #136 from Resellio/feat/return-ticket-used
kTrzcinskii Jun 14, 2025
7280a13
Merge branch 'feat/blob' of https://github.com/Resellio/api into feat…
staszkiet Jun 14, 2025
d005f14
Merge branch 'develop' of https://github.com/Resellio/api into feat/blob
staszkiet Jun 14, 2025
b71fea8
minor fixes
staszkiet Jun 14, 2025
85457a0
try catch block
staszkiet Jun 14, 2025
3fd5c9c
better try catch
staszkiet Jun 14, 2025
77d7dd0
Merge pull request #138 from Resellio/feat/blob
staszkiet Jun 14, 2025
b8da76a
resolved comment
kubapoke Jun 14, 2025
0ce2e03
Merge pull request #137 from Resellio/feat/shopping-cart-for-resells
kTrzcinskii Jun 14, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,11 @@ public async Task CreateEvent_WhenDataIsValid_ShouldReturnSuccess()
new CreateEventTicketTypeDto("V.I.P", 10, 500.9m, "zł", new DateTime(2025, 5, 10)),
];
CreateAddressDto createAddress = new CreateAddressDto("United States", "New York", "Main st", 20, null, "00-000");
CreateEventDto eventDto = new CreateEventDto(name, description, startDate, endDate, minimumAge, categories, ticketTypes, eventStatus, createAddress);
CreateEventDto eventDto = new CreateEventDto(name, description, startDate, endDate, minimumAge, categories, ticketTypes, eventStatus, createAddress, null);

var eventServiceMock = new Mock<IEventService>();
eventServiceMock
.Setup(m => m.CreateNewEventAsync(name, description, startDate, endDate, minimumAge, createAddress, categories , ticketTypes, eventStatus, email))
.Setup(m => m.CreateNewEventAsync(name, description, startDate, endDate, minimumAge, createAddress, categories , ticketTypes, eventStatus, email, null))
.ReturnsAsync(Result<Event>.Success(new Event()));

var claims = new List<Claim>
Expand Down Expand Up @@ -123,7 +123,7 @@ public async Task CreateEvent_WhenMissingEmailClaims_ShouldReturnBadRequest()
};

// act
var res = await sut.CreateEvent(new CreateEventDto(name, description, startDate, endDate, minimumAge, categories, ticketTypes, eventStatus, createAddress));
var res = await sut.CreateEvent(new CreateEventDto(name, description, startDate, endDate, minimumAge, categories, ticketTypes, eventStatus, createAddress, null));

// Assert
var result = Assert.IsType<ActionResult<CreateEventResponseDto>>(res);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,7 @@ public void ApplyFilters_WithName_ShouldCallFilterByName()
_eventFilterApplier.ApplyFilters(filters);

// Assert
_mockEventFilter.Verify(ef => ef.FilterByName(filters.SearchQuery!), Times.Once);
_mockEventFilter.Verify(ef => ef.FilterByDescription(filters.SearchQuery!), Times.Once);
_mockEventFilter.Verify(ef => ef.FilterByNameOrDescription(filters.SearchQuery!), Times.Once);
_mockEventFilter.Verify(ef => ef.GetEvents(), Times.Once);
}

Expand Down Expand Up @@ -660,8 +659,7 @@ public void ApplyFilters_WithMultipleFilters_ShouldCallAllRelevantFilters()
_eventFilterApplier.ApplyFilters(filters);

// Assert
_mockEventFilter.Verify(ef => ef.FilterByName(filters.SearchQuery!), Times.Once);
_mockEventFilter.Verify(ef => ef.FilterByDescription(filters.SearchQuery!), Times.Once);
_mockEventFilter.Verify(ef => ef.FilterByNameOrDescription(filters.SearchQuery!), Times.Once);
_mockEventFilter.Verify(ef => ef.FilterByStartDate(filters.StartDate!.Value), Times.Once);
_mockEventFilter.Verify(ef => ef.FilterByMinPrice(filters.MinPrice!.Value), Times.Once);
_mockEventFilter.Verify(ef => ef.FilterByMaxPrice(filters.MaxPrice!.Value), Times.Once);
Expand Down Expand Up @@ -703,8 +701,7 @@ public void ApplyFilters_WithNoFilters_ShouldOnlyCallGetEvents()
var result = _eventFilterApplier.ApplyFilters(filters);

// Assert
_mockEventFilter.Verify(ef => ef.FilterByName(It.IsAny<string>()), Times.Never);
_mockEventFilter.Verify(ef => ef.FilterByDescription(It.IsAny<string>()), Times.Never);
_mockEventFilter.Verify(ef => ef.FilterByNameOrDescription(It.IsAny<string>()), Times.Never);
_mockEventFilter.Verify(ef => ef.FilterByStartDate(It.IsAny<DateTime>()), Times.Never);
_mockEventFilter.Verify(ef => ef.FilterByMinStartDate(It.IsAny<DateTime>()), Times.Never);
_mockEventFilter.Verify(ef => ef.FilterByMaxStartDate(It.IsAny<DateTime>()), Times.Never);
Expand Down
4 changes: 2 additions & 2 deletions TickAPI/TickAPI.Tests/Events/Filters/EventFilterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ public void FilterByName_ShouldReturnMatchingEvents()

// Act
var eventFilter = new EventFilter(events.AsQueryable());
eventFilter.FilterByName("concert");
eventFilter.FilterByNameOrDescription("concert");
var result = eventFilter.GetEvents().ToList();

// Assert
Expand All @@ -119,7 +119,7 @@ public void FilterByDescription_ShouldReturnMatchingEvents()

// Act
var eventFilter = new EventFilter(events.AsQueryable());
eventFilter.FilterByDescription("tech");
eventFilter.FilterByNameOrDescription("tech");
var result = eventFilter.GetEvents().ToList();

// Assert
Expand Down
207 changes: 106 additions & 101 deletions TickAPI/TickAPI.Tests/Events/Services/EventServiceTests.cs

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions TickAPI/TickAPI.Tests/Events/Utils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ public static GetEventResponseDto CreateSampleEventResponseDto(string name)
new GetEventResponsePriceInfoDto(300, "PLN"),
[new GetEventResponseCategoryDto("Test")],
EventStatus.TicketsAvailable,
new GetEventResponseAddressDto("United States", "New York", "10001", "Main St", 123, null)
new GetEventResponseAddressDto("United States", "New York", "10001", "Main St", 123, null),
null
);
}

Expand All @@ -69,7 +70,8 @@ [new GetEventResponseCategoryDto("Test")],
new GetEventDetailsResponseTicketTypeDto(Guid.Parse("7ecfc61a-32d2-4124-a95c-cb5834a49990"), "Description #2", 300, "PLN", new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc), 30),
new GetEventDetailsResponseTicketTypeDto(Guid.Parse("7be2ae57-2394-4854-bf11-9567ce7e0ab6"), "Description #3", 200, "PLN", new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc), 20)],
EventStatus.TicketsAvailable,
new GetEventResponseAddressDto("United States", "New York", "10001", "Main St", 123, null)
new GetEventResponseAddressDto("United States", "New York", "10001", "Main St", 123, null),
null
);
}
}
6 changes: 6 additions & 0 deletions TickAPI/TickAPI/Common/Blob/Abstractions/IBlobService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace TickAPI.Common.Blob.Abstractions;

public interface IBlobService
{
public Task<string> UploadToBlobContainerAsync(IFormFile image);
}
32 changes: 32 additions & 0 deletions TickAPI/TickAPI/Common/Blob/Services/BlobService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using Azure.Identity;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using TickAPI.Common.Blob.Abstractions;

namespace TickAPI.Common.Blob.Services;

public class BlobService : IBlobService
{
private string _connectionString;
private string _containerName;

public BlobService(IConfiguration configuration)
{
_connectionString = configuration["BlobStorage:ConnectionString"];
_containerName = configuration["BlobStorage:ContainerName"];
}

public async Task<string> UploadToBlobContainerAsync(IFormFile image)
{
var container = new BlobContainerClient(_connectionString, _containerName);
Guid id = Guid.NewGuid();
string blobName = id.ToString();
var blob = container.GetBlobClient(blobName);
var stream = new MemoryStream();
await image.CopyToAsync(stream);
stream.Position = 0;
var response = await blob.UploadAsync(stream);

return blob.Uri.ToString();
}
}
5 changes: 2 additions & 3 deletions TickAPI/TickAPI/Common/Mail/Abstractions/IMailService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ namespace TickAPI.Common.Mail.Abstractions;

public interface IMailService
{
public Task<Result> SendTicketAsync(string toEmail, string toLogin, string eventName, byte[] pdfData);
public Task<Result> SendTicketAsync(MailRecipient recipient, string eventName, byte[] pdfData);

public Task<Result> SendMailAsync(string toEmail, string toLogin, string subject, string content,
List<MailAttachment>? attachments);
public Task<Result> SendMailAsync(IEnumerable<MailRecipient> recipients, string subject, string content, List<MailAttachment>? attachments);
}
11 changes: 5 additions & 6 deletions TickAPI/TickAPI/Common/Mail/Models/MailAttachment.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
namespace TickAPI.Common.Mail.Models;

public class MailAttachment
{
public string fileName { get; set; }
public string base64Content { get; set; }
public string fileType { get; set; }
}
public record MailAttachment(
string FileName,
string Base64Content,
string FileType
);
6 changes: 6 additions & 0 deletions TickAPI/TickAPI/Common/Mail/Models/MailRecipient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace TickAPI.Common.Mail.Models;

public record MailRecipient(
string Email,
string Login
);
24 changes: 9 additions & 15 deletions TickAPI/TickAPI/Common/Mail/Services/MailService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,35 +20,29 @@ public MailService(IConfiguration configuration)
_fromEmailAddress = new EmailAddress(fromEmail, fromName);
}

public async Task<Result> SendTicketAsync(string toEmail, string toLogin, string eventName, byte[] pdfData)
public async Task<Result> SendTicketAsync(MailRecipient recipient, string eventName, byte[] pdfData)
{
var subject = $"Ticket for {eventName}";
var htmlContent = "<strong>Download your ticket from attachments</strong>";
var base64Content = Convert.ToBase64String(pdfData);
List<MailAttachment> attachments =
[
new MailAttachment
{
base64Content = base64Content,
fileName = "ticket.pdf",
fileType = "application/pdf"
}
List<MailAttachment> attachments = [
new MailAttachment("ticket.pdf", base64Content, "application/pdf")
];
var res = await SendMailAsync(toEmail, toLogin, subject, htmlContent, attachments);
var res = await SendMailAsync([recipient], subject, htmlContent, attachments);
return res;
}

public async Task<Result> SendMailAsync(string toEmail, string toLogin, string subject, string content,
public async Task<Result> SendMailAsync(IEnumerable<MailRecipient> recipients, string subject, string content,
List<MailAttachment>? attachments = null)
{
var toEmailAddress = new EmailAddress(toEmail, toLogin);
var msg = MailHelper.CreateSingleEmail(_fromEmailAddress, toEmailAddress, subject,
null, content);
var toEmailAddresses = recipients.Select(r => new EmailAddress(r.Email, r.Login)).ToList();
var msg = MailHelper.CreateSingleEmailToMultipleRecipients(_fromEmailAddress, toEmailAddresses, subject, null, content);

if (attachments != null)
{
foreach (var a in attachments)
{
msg.AddAttachment(a.fileName, a.base64Content, a.fileType);
msg.AddAttachment(a.FileName, a.Base64Content, a.FileType);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ public interface ICustomerRepository
{
Task<Result<Customer>> GetCustomerByEmailAsync(string customerEmail);
Task AddNewCustomerAsync(Customer customer);
IQueryable<Customer> GetCustomersWithTicketForEvent(Guid eventId);
}
6 changes: 6 additions & 0 deletions TickAPI/TickAPI/Customers/Repositories/CustomerRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,10 @@ public async Task AddNewCustomerAsync(Customer customer)
_tickApiDbContext.Customers.Add(customer);
await _tickApiDbContext.SaveChangesAsync();
}

public IQueryable<Customer> GetCustomersWithTicketForEvent(Guid eventId)
{
return _tickApiDbContext.Customers
.Where(c => c.Tickets.Any(t => t.Type.Event.Id == eventId));
}
}
3 changes: 1 addition & 2 deletions TickAPI/TickAPI/Events/Abstractions/IEventFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ namespace TickAPI.Events.Abstractions;
public interface IEventFilter
{
IQueryable<Event> GetEvents();
void FilterByName(string name);
void FilterByDescription(string description);
void FilterByNameOrDescription(string name);
void FilterByStartDate(DateTime startDate);
void FilterByMinStartDate(DateTime startDate);
void FilterByMaxStartDate(DateTime startDate);
Expand Down
2 changes: 2 additions & 0 deletions TickAPI/TickAPI/Events/Abstractions/IEventRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ public interface IEventRepository
public Task<Result<Event>> GetEventByIdAsync(Guid eventId);
public Task<Result> SaveEventAsync(Event ev);
public Task<Result<Event>> GetEventByIdAndOrganizerAsync(Guid eventId, Organizer organizer);
public Task<decimal> GetEventRevenue(Guid eventId);
public Task<int> GetEventSoldTicketsCount(Guid eventId);
}
5 changes: 4 additions & 1 deletion TickAPI/TickAPI/Events/Abstractions/IEventService.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using TickAPI.Addresses.DTOs.Request;
using TickAPI.Common.Pagination.Responses;
using TickAPI.Categories.DTOs.Request;
using TickAPI.Common.Results;
using TickAPI.Events.Models;
using TickAPI.Common.Results.Generic;
using TickAPI.Events.DTOs.Request;
Expand All @@ -14,11 +15,13 @@ public interface IEventService
{
public Task<Result<Event>> CreateNewEventAsync(string name, string description, DateTime startDate,
DateTime endDate, uint? minimumAge, CreateAddressDto createAddress, List<CreateEventCategoryDto> categories,
List<CreateEventTicketTypeDto> ticketTypes,EventStatus eventStatus, string organizerEmail);
List<CreateEventTicketTypeDto> ticketTypes,EventStatus eventStatus, string organizerEmail, IFormFile? images);
public Task<Result<PaginatedData<GetEventResponseDto>>> GetOrganizerEventsAsync(Organizer organizer, int page, int pageSize, EventFiltersDto? eventFilters = null);
public Task<Result<PaginationDetails>> GetOrganizerEventsPaginationDetailsAsync(Organizer organizer, int pageSize);
public Task<Result<PaginatedData<GetEventResponseDto>>> GetEventsAsync(int page, int pageSize, EventFiltersDto? eventFilters = null);
public Task<Result<PaginationDetails>> GetEventsPaginationDetailsAsync(int pageSize);
public Task<Result<GetEventDetailsResponseDto>> GetEventDetailsAsync(Guid eventId);
public Task<Result<Event>> EditEventAsync(Organizer organizer, Guid eventId, string name, string description, DateTime startDate, DateTime endDate, uint? minimumAge, CreateAddressDto editAddress, List<EditEventCategoryDto> categories, EventStatus eventStatus);
public Task<Result> SendMessageToParticipants(Organizer organizer, Guid eventId, string subject, string message);
public Task<Result<GetEventDetailsOrganizerResponseDto>> GetEventDetailsOrganizerAsync(Guid eventId);
}
34 changes: 32 additions & 2 deletions TickAPI/TickAPI/Events/Controllers/EventsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public EventsController(IEventService eventService, IClaimsService claimsService

[AuthorizeWithPolicy(AuthPolicies.VerifiedOrganizerPolicy)]
[HttpPost]
public async Task<ActionResult<CreateEventResponseDto>> CreateEvent([FromBody] CreateEventDto request)
public async Task<ActionResult<CreateEventResponseDto>> CreateEvent([FromForm] CreateEventDto request)
{
var emailResult = _claimsService.GetEmailFromClaims(User.Claims);
if (emailResult.IsError)
Expand All @@ -39,7 +39,7 @@ public async Task<ActionResult<CreateEventResponseDto>> CreateEvent([FromBody] C

var newEventResult = await _eventService.CreateNewEventAsync(request.Name, request.Description,
request.StartDate, request.EndDate, request.MinimumAge, request.CreateAddress, request.Categories
, request.TicketTypes ,request.EventStatus, email);
, request.TicketTypes ,request.EventStatus, email, request.Image);

if (newEventResult.IsError)
return newEventResult.ToObjectResult();
Expand Down Expand Up @@ -115,6 +115,14 @@ public async Task<ActionResult<GetEventDetailsResponseDto>> GetEventDetails([Fro
return eventDetailsResult.ToObjectResult();
}

[AuthorizeWithPolicy(AuthPolicies.VerifiedOrganizerPolicy)]
[HttpGet("organizer/{id:guid}")]
public async Task<ActionResult<GetEventDetailsOrganizerResponseDto>> GetEventDetailsOrganizer([FromRoute] Guid id)
{
var eventDetailsResult = await _eventService.GetEventDetailsOrganizerAsync(id);
return eventDetailsResult.ToObjectResult();
}

[AuthorizeWithPolicy(AuthPolicies.VerifiedOrganizerPolicy)]
[HttpPatch("{id:guid}")]
public async Task<ActionResult<EditEventResponseDto>> EditEvent([FromRoute] Guid id, [FromBody] EditEventDto request)
Expand All @@ -141,4 +149,26 @@ public async Task<ActionResult<EditEventResponseDto>> EditEvent([FromRoute] Guid

return Ok("Event edited succesfully");
}

[AuthorizeWithPolicy(AuthPolicies.VerifiedOrganizerPolicy)]
[HttpPost("{id:guid}/message-to-participants")]
public async Task<ActionResult> SendMessageToEventParticipants([FromRoute] Guid id, [FromBody] SendMessageToParticipantsDto request)
{
var emailResult = _claimsService.GetEmailFromClaims(User.Claims);
if (emailResult.IsError)
{
return emailResult.ToObjectResult();
}
var email = emailResult.Value!;

var organizerResult = await _organizerService.GetOrganizerByEmailAsync(email);
if (organizerResult.IsError)
{
return organizerResult.ToObjectResult();
}
var organizer = organizerResult.Value!;

var result = await _eventService.SendMessageToParticipants(organizer, id, request.Subject, request.Message);
return result.ToObjectResult();
}
}
3 changes: 2 additions & 1 deletion TickAPI/TickAPI/Events/DTOs/Request/CreateEventDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ public record CreateEventDto(
List<CreateEventCategoryDto> Categories,
List<CreateEventTicketTypeDto> TicketTypes,
EventStatus EventStatus,
CreateAddressDto CreateAddress
CreateAddressDto CreateAddress,
IFormFile? Image
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace TickAPI.Events.DTOs.Request;

public record SendMessageToParticipantsDto(
string Subject,
string Message
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using TickAPI.Events.Models;
using TickAPI.Events.DTOs.Response;

namespace TickAPI.Events.DTOs.Response;


public record GetEventDetailsOrganizerResponseDto(
Guid Id,
string Name,
string Description,
DateTime StartDate,
DateTime EndDate,
uint? MinimumAge,
List<GetEventResponseCategoryDto> Categories,
List<GetEventDetailsResponseTicketTypeDto> TicketTypes,
EventStatus Status,
GetEventResponseAddressDto Address,
decimal Revenue,
int SoldTicketsCount
);


Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ public record GetEventDetailsResponseDto(
List<GetEventResponseCategoryDto> Categories,
List<GetEventDetailsResponseTicketTypeDto> TicketTypes,
EventStatus Status,
GetEventResponseAddressDto Address
GetEventResponseAddressDto Address,
string? ImageUrl
);
3 changes: 2 additions & 1 deletion TickAPI/TickAPI/Events/DTOs/Response/GetEventResponseDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ public record GetEventResponseDto(
GetEventResponsePriceInfoDto MaximumPrice,
List<GetEventResponseCategoryDto> Categories,
EventStatus Status,
GetEventResponseAddressDto Address
GetEventResponseAddressDto Address,
string? ImageUrl
);
Loading
Loading