Fase 3: Implementación de Listings (Avisos) - Entidades, Repositorio, API y Frontend Wizard integración
This commit is contained in:
@@ -1,10 +1,21 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { useWizardStore } from './store/wizardStore';
|
import { useWizardStore } from './store/wizardStore';
|
||||||
import CategorySelection from './pages/Steps/CategorySelection';
|
import CategorySelection from './pages/Steps/CategorySelection';
|
||||||
import OperationSelection from './pages/Steps/OperationSelection';
|
import OperationSelection from './pages/Steps/OperationSelection';
|
||||||
import AttributeForm from './pages/Steps/AttributeForm';
|
import AttributeForm from './pages/Steps/AttributeForm';
|
||||||
|
import SummaryStep from './pages/Steps/SummaryStep';
|
||||||
|
import { wizardService } from './services/wizardService';
|
||||||
|
import type { AttributeDefinition } from './types';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const step = useWizardStore((state) => state.step);
|
const { step, selectedCategory } = useWizardStore();
|
||||||
|
const [definitions, setDefinitions] = useState<AttributeDefinition[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedCategory) {
|
||||||
|
wizardService.getAttributes(selectedCategory.id).then(setDefinitions);
|
||||||
|
}
|
||||||
|
}, [selectedCategory]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 text-slate-900 font-sans">
|
<div className="min-h-screen bg-slate-50 text-slate-900 font-sans">
|
||||||
@@ -30,8 +41,8 @@ function App() {
|
|||||||
{step === 1 && <CategorySelection />}
|
{step === 1 && <CategorySelection />}
|
||||||
{step === 2 && <OperationSelection />}
|
{step === 2 && <OperationSelection />}
|
||||||
{step === 3 && <AttributeForm />}
|
{step === 3 && <AttributeForm />}
|
||||||
{step === 4 && <div className="text-center py-20">Paso 4: Fotos (Coming Soon)</div>}
|
{step === 4 && <div className="text-center py-20">Paso 4: Fotos (Coming Soon) - <button onClick={() => useWizardStore.getState().setStep(5)} className="text-blue-500 underline">Saltar</button></div>}
|
||||||
{step === 5 && <div className="text-center py-20">Paso 5: Resumen y Pago (Coming Soon)</div>}
|
{step === 5 && <SummaryStep definitions={definitions} />}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
108
frontend/publish-wizard/src/pages/Steps/SummaryStep.tsx
Normal file
108
frontend/publish-wizard/src/pages/Steps/SummaryStep.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useWizardStore } from '../../store/wizardStore';
|
||||||
|
import { wizardService } from '../../services/wizardService';
|
||||||
|
import { StepWrapper } from '../../components/StepWrapper';
|
||||||
|
import type { AttributeDefinition } from '../../types';
|
||||||
|
|
||||||
|
export default function SummaryStep({ definitions }: { definitions: AttributeDefinition[] }) {
|
||||||
|
const { selectedCategory, selectedOperation, attributes, setStep } = useWizardStore();
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [createdId, setCreatedId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const handlePublish = async () => {
|
||||||
|
if (!selectedCategory || !selectedOperation) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const attributePayload: Record<number, string> = {};
|
||||||
|
// Ideally we should have stored definitions in store or passed them.
|
||||||
|
// For now assuming 'definitions' prop contains current category definitions
|
||||||
|
|
||||||
|
// For now assuming 'definitions' prop contains current category definitions
|
||||||
|
|
||||||
|
definitions.forEach(def => {
|
||||||
|
if (attributes[def.name]) {
|
||||||
|
attributePayload[def.id] = attributes[def.name].toString();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
categoryId: selectedCategory.id,
|
||||||
|
operationId: selectedOperation.id,
|
||||||
|
title: attributes['title'],
|
||||||
|
description: 'Generated via Wizard', // Todo: Add description field
|
||||||
|
price: parseFloat(attributes['price']),
|
||||||
|
currency: 'ARS',
|
||||||
|
attributes: attributePayload
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await wizardService.createListing(payload);
|
||||||
|
setCreatedId(result.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
alert('Error al publicar');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (createdId) {
|
||||||
|
return (
|
||||||
|
<StepWrapper>
|
||||||
|
<div className="text-center py-10">
|
||||||
|
<div className="text-4xl text-green-500 mb-4">✓</div>
|
||||||
|
<h2 className="text-2xl font-bold mb-2">¡Aviso Publicado!</h2>
|
||||||
|
<p className="text-gray-500">ID de referencia: #{createdId}</p>
|
||||||
|
<button onClick={() => window.location.reload()} className="mt-8 text-brand-600 underline">Publicar otro</button>
|
||||||
|
</div>
|
||||||
|
</StepWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StepWrapper>
|
||||||
|
<h2 className="text-2xl font-bold mb-6 text-brand-900">Resumen y Confirmación</h2>
|
||||||
|
|
||||||
|
<div className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm mb-6">
|
||||||
|
<div className="mb-4">
|
||||||
|
<span className="block text-xs text-slate-500 uppercase tracking-wide">Categoría</span>
|
||||||
|
<span className="font-semibold text-lg">{selectedCategory?.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<span className="block text-xs text-slate-500 uppercase tracking-wide">Operación</span>
|
||||||
|
<span className="font-semibold text-lg">{selectedOperation?.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-4 mt-4">
|
||||||
|
<h3 className="font-bold mb-2">{attributes['title']}</h3>
|
||||||
|
<div className="text-2xl font-bold text-green-600 mb-4">$ {attributes['price']}</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
{definitions.map(def => attributes[def.name] && (
|
||||||
|
<div key={def.id}>
|
||||||
|
<span className="text-slate-500">{def.name}:</span> <span className="font-medium">{attributes[def.name]}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setStep(3)}
|
||||||
|
className="flex-1 py-3 text-slate-600 hover:bg-slate-100 rounded-lg"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Volver
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handlePublish}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="flex-1 py-3 bg-brand-600 text-white font-bold rounded-lg hover:bg-brand-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Publicando...' : 'Confirmar y Publicar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</StepWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import api from './api';
|
import api from './api';
|
||||||
import { Category, Operation, AttributeDefinition } from '../types';
|
import type { Category, Operation, AttributeDefinition } from '../types'; // ID: type-import-fix
|
||||||
|
|
||||||
export const wizardService = {
|
export const wizardService = {
|
||||||
getCategories: async (): Promise<Category[]> => {
|
getCategories: async (): Promise<Category[]> => {
|
||||||
@@ -15,5 +15,10 @@ export const wizardService = {
|
|||||||
getAttributes: async (categoryId: number): Promise<AttributeDefinition[]> => {
|
getAttributes: async (categoryId: number): Promise<AttributeDefinition[]> => {
|
||||||
const response = await api.get<AttributeDefinition[]>(`/attributedefinitions/category/${categoryId}`);
|
const response = await api.get<AttributeDefinition[]>(`/attributedefinitions/category/${categoryId}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createListing: async (data: any): Promise<{ id: number }> => {
|
||||||
|
const response = await api.post<{ id: number }>('/listings', data);
|
||||||
|
return response.data;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
15
frontend/publish-wizard/src/types/listing.ts
Normal file
15
frontend/publish-wizard/src/types/listing.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export interface CreateListingDto {
|
||||||
|
categoryId: number;
|
||||||
|
operationId: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
price: number;
|
||||||
|
currency: string;
|
||||||
|
userId?: number;
|
||||||
|
attributes: Record<string, string>; // definitionId -> Value
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Listing {
|
||||||
|
id: number;
|
||||||
|
// ...
|
||||||
|
}
|
||||||
46
src/SIGCM.API/Controllers/ListingsController.cs
Normal file
46
src/SIGCM.API/Controllers/ListingsController.cs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SIGCM.Application.DTOs;
|
||||||
|
using SIGCM.Domain.Entities;
|
||||||
|
using SIGCM.Domain.Interfaces;
|
||||||
|
|
||||||
|
namespace SIGCM.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class ListingsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IListingRepository _repository;
|
||||||
|
|
||||||
|
public ListingsController(IListingRepository repository)
|
||||||
|
{
|
||||||
|
_repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create(CreateListingDto dto)
|
||||||
|
{
|
||||||
|
var listing = new Listing
|
||||||
|
{
|
||||||
|
CategoryId = dto.CategoryId,
|
||||||
|
OperationId = dto.OperationId,
|
||||||
|
Title = dto.Title,
|
||||||
|
Description = dto.Description,
|
||||||
|
Price = dto.Price,
|
||||||
|
Currency = dto.Currency,
|
||||||
|
UserId = dto.UserId,
|
||||||
|
Status = "Draft",
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
var id = await _repository.CreateAsync(listing, dto.Attributes);
|
||||||
|
return Ok(new { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public async Task<IActionResult> Get(int id)
|
||||||
|
{
|
||||||
|
var listing = await _repository.GetByIdAsync(id);
|
||||||
|
if (listing == null) return NotFound();
|
||||||
|
return Ok(listing);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/SIGCM.Application/DTOs/ListingDtos.cs
Normal file
22
src/SIGCM.Application/DTOs/ListingDtos.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
namespace SIGCM.Application.DTOs;
|
||||||
|
|
||||||
|
public class CreateListingDto
|
||||||
|
{
|
||||||
|
public int CategoryId { get; set; }
|
||||||
|
public int OperationId { get; set; }
|
||||||
|
public required string Title { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public decimal Price { get; set; }
|
||||||
|
public string Currency { get; set; } = "ARS";
|
||||||
|
public int? UserId { get; set; }
|
||||||
|
|
||||||
|
// Dictionary of AttributeDefinitionId -> Value
|
||||||
|
public Dictionary<int, string> Attributes { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ListingDto : CreateListingDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public string Status { get; set; }
|
||||||
|
}
|
||||||
17
src/SIGCM.Domain/Entities/Listing.cs
Normal file
17
src/SIGCM.Domain/Entities/Listing.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
namespace SIGCM.Domain.Entities;
|
||||||
|
|
||||||
|
public class Listing
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int CategoryId { get; set; }
|
||||||
|
public int OperationId { get; set; }
|
||||||
|
public required string Title { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public decimal Price { get; set; }
|
||||||
|
public string Currency { get; set; } = "ARS"; // ARS, USD
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public string Status { get; set; } = "Draft"; // Draft, Published, Sold, Paused
|
||||||
|
public int? UserId { get; set; }
|
||||||
|
|
||||||
|
// Navigation properties logic will be handled manually via repositories in Dapper
|
||||||
|
}
|
||||||
9
src/SIGCM.Domain/Entities/ListingAttributeValue.cs
Normal file
9
src/SIGCM.Domain/Entities/ListingAttributeValue.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace SIGCM.Domain.Entities;
|
||||||
|
|
||||||
|
public class ListingAttributeValue
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int ListingId { get; set; }
|
||||||
|
public int AttributeDefinitionId { get; set; }
|
||||||
|
public required string Value { get; set; }
|
||||||
|
}
|
||||||
10
src/SIGCM.Domain/Interfaces/IListingRepository.cs
Normal file
10
src/SIGCM.Domain/Interfaces/IListingRepository.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
using SIGCM.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM.Domain.Interfaces;
|
||||||
|
|
||||||
|
public interface IListingRepository
|
||||||
|
{
|
||||||
|
Task<int> CreateAsync(Listing listing, Dictionary<int, string> attributes);
|
||||||
|
Task<Listing?> GetByIdAsync(int id);
|
||||||
|
Task<IEnumerable<Listing>> GetAllAsync();
|
||||||
|
}
|
||||||
@@ -123,6 +123,37 @@ BEGIN
|
|||||||
FOREIGN KEY (CategoryId) REFERENCES Categories(Id) ON DELETE CASCADE
|
FOREIGN KEY (CategoryId) REFERENCES Categories(Id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
END
|
END
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Listings')
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE Listings (
|
||||||
|
Id INT IDENTITY(1,1) PRIMARY KEY,
|
||||||
|
CategoryId INT NOT NULL,
|
||||||
|
OperationId INT NOT NULL,
|
||||||
|
Title NVARCHAR(200) NOT NULL,
|
||||||
|
Description NVARCHAR(MAX) NULL,
|
||||||
|
Price DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||||
|
Currency NVARCHAR(3) DEFAULT 'ARS',
|
||||||
|
CreatedAt DATETIME2 DEFAULT GETUTCDATE(),
|
||||||
|
Status NVARCHAR(20) DEFAULT 'Draft',
|
||||||
|
UserId INT NULL,
|
||||||
|
FOREIGN KEY (CategoryId) REFERENCES Categories(Id),
|
||||||
|
FOREIGN KEY (OperationId) REFERENCES Operations(Id),
|
||||||
|
FOREIGN KEY (UserId) REFERENCES Users(Id)
|
||||||
|
);
|
||||||
|
END
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'ListingAttributeValues')
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE ListingAttributeValues (
|
||||||
|
Id INT IDENTITY(1,1) PRIMARY KEY,
|
||||||
|
ListingId INT NOT NULL,
|
||||||
|
AttributeDefinitionId INT NOT NULL,
|
||||||
|
Value NVARCHAR(MAX) NOT NULL,
|
||||||
|
FOREIGN KEY (ListingId) REFERENCES Listings(Id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (AttributeDefinitionId) REFERENCES AttributeDefinitions(Id) ON DELETE NO ACTION
|
||||||
|
);
|
||||||
|
END
|
||||||
";
|
";
|
||||||
await connection.ExecuteAsync(schemaSql);
|
await connection.ExecuteAsync(schemaSql);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<ITokenService, Services.TokenService>();
|
services.AddScoped<ITokenService, Services.TokenService>();
|
||||||
services.AddScoped<IAuthService, Services.AuthService>();
|
services.AddScoped<IAuthService, Services.AuthService>();
|
||||||
services.AddScoped<IAttributeDefinitionRepository, AttributeDefinitionRepository>();
|
services.AddScoped<IAttributeDefinitionRepository, AttributeDefinitionRepository>();
|
||||||
|
services.AddScoped<IListingRepository, ListingRepository>();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,3 +26,4 @@ public static class DependencyInjection
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
73
src/SIGCM.Infrastructure/Repositories/ListingRepository.cs
Normal file
73
src/SIGCM.Infrastructure/Repositories/ListingRepository.cs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
using System.Data;
|
||||||
|
using Dapper;
|
||||||
|
using SIGCM.Application.DTOs;
|
||||||
|
using SIGCM.Domain.Entities;
|
||||||
|
using SIGCM.Domain.Interfaces;
|
||||||
|
using SIGCM.Infrastructure.Data;
|
||||||
|
|
||||||
|
namespace SIGCM.Infrastructure.Repositories;
|
||||||
|
|
||||||
|
public class ListingRepository : IListingRepository
|
||||||
|
{
|
||||||
|
private readonly IDbConnectionFactory _connectionFactory;
|
||||||
|
|
||||||
|
public ListingRepository(IDbConnectionFactory connectionFactory)
|
||||||
|
{
|
||||||
|
_connectionFactory = connectionFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> CreateAsync(Listing listing, Dictionary<int, string> attributes)
|
||||||
|
{
|
||||||
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
|
conn.Open();
|
||||||
|
using var transaction = conn.BeginTransaction();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sqlListing = @"
|
||||||
|
INSERT INTO Listings (CategoryId, OperationId, Title, Description, Price, Currency, CreatedAt, Status, UserId)
|
||||||
|
VALUES (@CategoryId, @OperationId, @Title, @Description, @Price, @Currency, @CreatedAt, @Status, @UserId);
|
||||||
|
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||||
|
|
||||||
|
var listingId = await conn.QuerySingleAsync<int>(sqlListing, listing, transaction);
|
||||||
|
|
||||||
|
if (attributes != null && attributes.Any())
|
||||||
|
{
|
||||||
|
var sqlAttr = @"
|
||||||
|
INSERT INTO ListingAttributeValues (ListingId, AttributeDefinitionId, Value)
|
||||||
|
VALUES (@ListingId, @AttributeDefinitionId, @Value)";
|
||||||
|
|
||||||
|
foreach (var attr in attributes)
|
||||||
|
{
|
||||||
|
await conn.ExecuteAsync(sqlAttr, new { ListingId = listingId, AttributeDefinitionId = attr.Key, Value = attr.Value }, transaction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction.Commit();
|
||||||
|
return listingId;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
transaction.Rollback();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Listing?> GetByIdAsync(int id)
|
||||||
|
{
|
||||||
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
|
return await conn.QuerySingleOrDefaultAsync<Listing>("SELECT * FROM Listings WHERE Id = @Id", new { Id = id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Listing>> GetAllAsync()
|
||||||
|
{
|
||||||
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
|
// A simple query for now
|
||||||
|
var sql = @"
|
||||||
|
SELECT l.*
|
||||||
|
FROM Listings l
|
||||||
|
ORDER BY l.CreatedAt DESC";
|
||||||
|
|
||||||
|
return await conn.QueryAsync<Listing>(sql);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user