Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd5acbab76 | |||
| 29f181d5e1 | |||
| dc7e7a70fc | |||
| 83d3272784 | |||
| 1bee5f91c6 | |||
| 3268a50c3e | |||
| f1c036d1d8 | |||
| 92fef1dfa8 | |||
| bb33928661 | |||
| b279a121ad |
@@ -0,0 +1,20 @@
|
|||||||
|
namespace Distribution.Features.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Typed HttpClient that proxies requests to the external Auth Service.
|
||||||
|
/// Registered via AddHttpClient<AuthServiceClient> in Program.cs.
|
||||||
|
/// BaseAddress is configured from AuthService:BaseUrl in appsettings.json.
|
||||||
|
///
|
||||||
|
/// TODO: Implement methods once Auth Service contract is agreed.
|
||||||
|
/// </summary>
|
||||||
|
public class AuthServiceClient(HttpClient http) : IAuthServiceClient
|
||||||
|
{
|
||||||
|
// TODO: implement proxy methods, e.g.:
|
||||||
|
//
|
||||||
|
// public async Task<AuthServiceResponse?> LoginAsync(LoginRequest request, CancellationToken ct)
|
||||||
|
// {
|
||||||
|
// var response = await http.PostAsJsonAsync("/TODO_AUTH_SERVICE_LOGIN_PATH", request, ct);
|
||||||
|
// if (!response.IsSuccessStatusCode) return null;
|
||||||
|
// return await response.Content.ReadFromJsonAsync<AuthServiceResponse>(ct);
|
||||||
|
// }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Distribution.Features.Auth;
|
||||||
|
|
||||||
|
public class AuthServiceOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "AuthService";
|
||||||
|
|
||||||
|
/// <summary>Base URL of the external Auth Service, e.g. "https://auth.internal".</summary>
|
||||||
|
public string BaseUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// TODO: add other config (API keys, timeout, etc.) once Auth Service is defined.
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace Distribution.Features.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Connector to the external Auth Service. This backend proxies all auth
|
||||||
|
/// traffic from the Next.js frontend through to the Auth Service — the
|
||||||
|
/// frontend never calls the Auth Service directly.
|
||||||
|
///
|
||||||
|
/// TODO: Fill in the method signatures once the Auth Service contract is agreed.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAuthServiceClient
|
||||||
|
{
|
||||||
|
// TODO: define methods based on Auth Service contract, e.g.:
|
||||||
|
// Task<AuthServiceResponse?> LoginAsync(LoginRequest request, CancellationToken ct);
|
||||||
|
// Task<AuthServiceResponse?> RefreshAsync(string refreshToken, CancellationToken ct);
|
||||||
|
// Task LogoutAsync(string token, CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -1,20 +1,16 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Brand;
|
namespace Distribution.Features.Brand;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/brands")]
|
[Route("api/brands")]
|
||||||
[Authorize]
|
|
||||||
public class BrandController(BrandService service) : ControllerBase
|
public class BrandController(BrandService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "items.view")]
|
|
||||||
public async Task<IActionResult> GetAll() =>
|
public async Task<IActionResult> GetAll() =>
|
||||||
Ok(await service.GetAllAsync());
|
Ok(await service.GetAllAsync());
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "items.view")]
|
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -22,7 +18,6 @@ public class BrandController(BrandService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "items.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateBrandRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateBrandRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
var result = await service.CreateAsync(request, ct);
|
||||||
@@ -30,7 +25,6 @@ public class BrandController(BrandService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
[Authorize(Policy = "items.update")]
|
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateBrandRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateBrandRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateAsync(id, request, ct);
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
@@ -38,7 +32,6 @@ public class BrandController(BrandService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
[Authorize(Policy = "items.delete")]
|
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var deleted = await service.DeleteAsync(id, ct);
|
var deleted = await service.DeleteAsync(id, ct);
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Category;
|
namespace Distribution.Features.Category;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
|
||||||
public class CategoryController(CategoryService service) : ControllerBase
|
public class CategoryController(CategoryService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("api/categories")]
|
[HttpGet("api/categories")]
|
||||||
[Authorize(Policy = "categories.view")]
|
|
||||||
public async Task<IActionResult> GetAll() =>
|
public async Task<IActionResult> GetAll() =>
|
||||||
Ok(await service.GetAllAsync());
|
Ok(await service.GetAllAsync());
|
||||||
|
|
||||||
[HttpGet("api/categories/{id:guid}")]
|
[HttpGet("api/categories/{id:guid}")]
|
||||||
[Authorize(Policy = "categories.view")]
|
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -21,7 +17,6 @@ public class CategoryController(CategoryService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("api/categories")]
|
[HttpPost("api/categories")]
|
||||||
[Authorize(Policy = "categories.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateCategoryRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateCategoryRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
var result = await service.CreateAsync(request, ct);
|
||||||
@@ -29,7 +24,6 @@ public class CategoryController(CategoryService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("api/categories/{id:guid}")]
|
[HttpPut("api/categories/{id:guid}")]
|
||||||
[Authorize(Policy = "categories.update")]
|
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateCategoryRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateCategoryRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateAsync(id, request, ct);
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
@@ -37,7 +31,6 @@ public class CategoryController(CategoryService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("api/categories/{id:guid}")]
|
[HttpDelete("api/categories/{id:guid}")]
|
||||||
[Authorize(Policy = "categories.delete")]
|
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var deleted = await service.DeleteAsync(id, ct);
|
var deleted = await service.DeleteAsync(id, ct);
|
||||||
@@ -45,7 +38,6 @@ public class CategoryController(CategoryService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("api/categories/{categoryId:guid}/subcategories")]
|
[HttpPost("api/categories/{categoryId:guid}/subcategories")]
|
||||||
[Authorize(Policy = "categories.create")]
|
|
||||||
public async Task<IActionResult> CreateSubCategory(Guid categoryId, CreateSubCategoryRequest request, CancellationToken ct)
|
public async Task<IActionResult> CreateSubCategory(Guid categoryId, CreateSubCategoryRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.CreateSubCategoryAsync(categoryId, request, ct);
|
var result = await service.CreateSubCategoryAsync(categoryId, request, ct);
|
||||||
@@ -53,7 +45,6 @@ public class CategoryController(CategoryService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("api/subcategories/{id:guid}")]
|
[HttpPut("api/subcategories/{id:guid}")]
|
||||||
[Authorize(Policy = "categories.update")]
|
|
||||||
public async Task<IActionResult> UpdateSubCategory(Guid id, UpdateSubCategoryRequest request, CancellationToken ct)
|
public async Task<IActionResult> UpdateSubCategory(Guid id, UpdateSubCategoryRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateSubCategoryAsync(id, request, ct);
|
var result = await service.UpdateSubCategoryAsync(id, request, ct);
|
||||||
@@ -61,7 +52,6 @@ public class CategoryController(CategoryService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("api/subcategories/{id:guid}")]
|
[HttpDelete("api/subcategories/{id:guid}")]
|
||||||
[Authorize(Policy = "categories.delete")]
|
|
||||||
public async Task<IActionResult> DeleteSubCategory(Guid id, CancellationToken ct)
|
public async Task<IActionResult> DeleteSubCategory(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var deleted = await service.DeleteSubCategoryAsync(id, ct);
|
var deleted = await service.DeleteSubCategoryAsync(id, ct);
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Distribution.Features.Claims;
|
namespace Distribution.Features.Claims;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
|
||||||
public class ClaimController(ClaimService service, ICurrentUserService currentUser) : ControllerBase
|
public class ClaimController(ClaimService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("api/claims/{id:guid}")]
|
[HttpGet("api/claims/{id:guid}")]
|
||||||
@@ -27,7 +25,6 @@ public class ClaimController(ClaimService service, ICurrentUserService currentUs
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("api/claims/{id:guid}/settle")]
|
[HttpPost("api/claims/{id:guid}/settle")]
|
||||||
[Authorize(Policy = "claims.settle")]
|
|
||||||
public async Task<IActionResult> Settle(Guid id, SettleClaimRequest request, CancellationToken ct)
|
public async Task<IActionResult> Settle(Guid id, SettleClaimRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.DeliveryRide;
|
namespace Distribution.Features.DeliveryRide;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
|
||||||
[Route("api")]
|
[Route("api")]
|
||||||
public class DeliveryTaskController(DeliveryTaskService service) : ControllerBase
|
public class DeliveryTaskController(DeliveryTaskService service) : ControllerBase
|
||||||
{
|
{
|
||||||
@@ -21,6 +19,7 @@ public class DeliveryTaskController(DeliveryTaskService service) : ControllerBas
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("rides/{rideId:guid}/tasks")]
|
[HttpGet("rides/{rideId:guid}/tasks")]
|
||||||
|
[HttpGet("rides/{rideId:guid}/delivery-tasks")]
|
||||||
public async Task<IActionResult> GetByRide(Guid rideId)
|
public async Task<IActionResult> GetByRide(Guid rideId)
|
||||||
{
|
{
|
||||||
var result = await service.GetByRideIdAsync(rideId);
|
var result = await service.GetByRideIdAsync(rideId);
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
using Distribution.Infra.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.DriverComplianceConfig;
|
|
||||||
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/driver-compliance-config")]
|
|
||||||
[Authorize]
|
|
||||||
public class DriverComplianceConfigController(DriverComplianceConfigService service, ICurrentUserService currentUser) : ControllerBase
|
|
||||||
{
|
|
||||||
[HttpGet]
|
|
||||||
[Authorize(Policy = "compliance.manage")]
|
|
||||||
public async Task<IActionResult> Get()
|
|
||||||
{
|
|
||||||
var result = await service.GetAsync();
|
|
||||||
return result is null ? NoContent() : Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPut]
|
|
||||||
[Authorize(Policy = "compliance.manage")]
|
|
||||||
public async Task<IActionResult> Upsert(UpdateDriverComplianceConfigRequest request, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
|
||||||
var result = await service.UpsertAsync(request, userId, ct);
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Distribution.Features.DriverComplianceConfig;
|
||||||
|
using Distribution.Infra.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Distribution.Features.DriverProfile;
|
namespace Distribution.Features.DriverProfile;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/driver-profiles")]
|
[Route("api/driver-profiles")]
|
||||||
[Authorize]
|
public class DriverProfileController(
|
||||||
public class DriverProfileController(DriverProfileService service) : ControllerBase
|
DriverProfileService service,
|
||||||
|
DriverComplianceConfigService complianceService,
|
||||||
|
ICurrentUserService currentUser) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "driver_profiles.view")]
|
|
||||||
public async Task<IActionResult> GetAll([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
public async Task<IActionResult> GetAll([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||||
{
|
{
|
||||||
var result = await service.GetAllAsync(page, pageSize);
|
var result = await service.GetAllAsync(page, pageSize);
|
||||||
@@ -17,7 +19,6 @@ public class DriverProfileController(DriverProfileService service) : ControllerB
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "driver_profiles.view")]
|
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -25,15 +26,24 @@ public class DriverProfileController(DriverProfileService service) : ControllerB
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "driver_profiles.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateDriverProfileRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateDriverProfileRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
var result = await service.CreateAsync(request, ct);
|
||||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||||
}
|
}
|
||||||
|
catch (KeyNotFoundException ex)
|
||||||
|
{
|
||||||
|
return NotFound(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return Conflict(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
[Authorize(Policy = "driver_profiles.update")]
|
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateDriverProfileRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateDriverProfileRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateAsync(id, request, ct);
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
@@ -41,10 +51,24 @@ public class DriverProfileController(DriverProfileService service) : ControllerB
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
[Authorize(Policy = "driver_profiles.deactivate")]
|
|
||||||
public async Task<IActionResult> Deactivate(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Deactivate(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var success = await service.DeactivateAsync(id, ct);
|
var success = await service.DeactivateAsync(id, ct);
|
||||||
return success ? NoContent() : NotFound();
|
return success ? NoContent() : NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("compliance")]
|
||||||
|
public async Task<IActionResult> GetCompliance()
|
||||||
|
{
|
||||||
|
var result = await complianceService.GetAsync();
|
||||||
|
return result is null ? NoContent() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("compliance")]
|
||||||
|
public async Task<IActionResult> UpsertCompliance(UpdateDriverComplianceConfigRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
var result = await complianceService.UpsertAsync(request, userId, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ public record DriverProfileResponse(
|
|||||||
DateTime CreatedAt);
|
DateTime CreatedAt);
|
||||||
|
|
||||||
public record CreateDriverProfileRequest(
|
public record CreateDriverProfileRequest(
|
||||||
Guid FK_UserId,
|
Guid UserId,
|
||||||
string LicenseNumber,
|
string LicenseNumber,
|
||||||
DateTime LicenseExpiry,
|
DateTime LicenseExpiry,
|
||||||
DateTime MedicalClearanceExpiry);
|
DateTime MedicalClearanceExpiry);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
|
using Distribution.Features.User;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
|
|
||||||
namespace Distribution.Features.DriverProfile;
|
namespace Distribution.Features.DriverProfile;
|
||||||
|
|
||||||
public class DriverProfileService(IDriverProfileRepository repo, IUnitOfWork uow)
|
public class DriverProfileService(IDriverProfileRepository repo, IUnitOfWork uow, IUserRepository userRepo)
|
||||||
{
|
{
|
||||||
public async Task<PagedResponse<DriverProfileResponse>> GetAllAsync(int page, int pageSize)
|
public async Task<PagedResponse<DriverProfileResponse>> GetAllAsync(int page, int pageSize)
|
||||||
{
|
{
|
||||||
@@ -21,13 +22,17 @@ public class DriverProfileService(IDriverProfileRepository repo, IUnitOfWork uow
|
|||||||
|
|
||||||
public async Task<DriverProfileResponse> CreateAsync(CreateDriverProfileRequest request, CancellationToken ct = default)
|
public async Task<DriverProfileResponse> CreateAsync(CreateDriverProfileRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var existing = await repo.GetByUserIdAsync(request.FK_UserId);
|
var user = await userRepo.GetByIdAsync(request.UserId);
|
||||||
|
if (user is null)
|
||||||
|
throw new KeyNotFoundException($"User {request.UserId} not found");
|
||||||
|
|
||||||
|
var existing = await repo.GetByUserIdAsync(request.UserId);
|
||||||
if (existing is not null && existing.IsActive)
|
if (existing is not null && existing.IsActive)
|
||||||
throw new InvalidOperationException("An active driver profile already exists for this user");
|
throw new InvalidOperationException("An active driver profile already exists for this user");
|
||||||
|
|
||||||
var profile = new global::Distribution.Models.DriverProfile
|
var profile = new global::Distribution.Models.DriverProfile
|
||||||
{
|
{
|
||||||
FK_UserId = request.FK_UserId,
|
FK_UserId = request.UserId,
|
||||||
LicenseNumber = request.LicenseNumber,
|
LicenseNumber = request.LicenseNumber,
|
||||||
LicenseExpiry = request.LicenseExpiry,
|
LicenseExpiry = request.LicenseExpiry,
|
||||||
MedicalClearanceExpiry = request.MedicalClearanceExpiry
|
MedicalClearanceExpiry = request.MedicalClearanceExpiry
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Distribution.Features.Grn;
|
namespace Distribution.Features.Grn;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
|
||||||
public class GrnController(GrnService service, ICurrentUserService currentUser) : ControllerBase
|
public class GrnController(GrnService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
{
|
{
|
||||||
|
[HttpGet("api/goods-receipts")]
|
||||||
|
public async Task<IActionResult> GetAll(
|
||||||
|
[FromQuery] Guid? purchaseOrderId,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20)
|
||||||
|
{
|
||||||
|
var result = await service.GetAllAsync(purchaseOrderId, page, pageSize);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("api/goods-receipts/{id:guid}")]
|
[HttpGet("api/goods-receipts/{id:guid}")]
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
@@ -15,6 +23,19 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
|
|||||||
return result is null ? NotFound() : Ok(result);
|
return result is null ? NotFound() : Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("api/goods-receipts")]
|
||||||
|
public async Task<IActionResult> CreateFlat(CreateFlatGrnRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var inner = new CreateGrnRequest(request.WarehouseId, request.Items);
|
||||||
|
var result = await service.CreateAsync(request.PurchaseOrderId, inner, userId, ct);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("api/purchase-orders/{id:guid}/goods-receipts")]
|
[HttpGet("api/purchase-orders/{id:guid}/goods-receipts")]
|
||||||
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
|
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
|
||||||
Ok(await service.GetByPurchaseOrderIdAsync(id));
|
Ok(await service.GetByPurchaseOrderIdAsync(id));
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ public record GrnItemRequest(Guid PurchaseOrderItemId, int AcceptedQuantity, int
|
|||||||
|
|
||||||
public record CreateGrnRequest(Guid WarehouseId, List<GrnItemRequest> Items);
|
public record CreateGrnRequest(Guid WarehouseId, List<GrnItemRequest> Items);
|
||||||
|
|
||||||
|
public record CreateFlatGrnRequest(Guid PurchaseOrderId, Guid WarehouseId, List<GrnItemRequest> Items);
|
||||||
|
|
||||||
public record GrnItemResponse(
|
public record GrnItemResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid PurchaseOrderItemId,
|
Guid PurchaseOrderItemId,
|
||||||
|
|||||||
@@ -11,6 +11,21 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
|
|||||||
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
|
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
|
||||||
.FirstOrDefaultAsync(gr => gr.Id == id);
|
.FirstOrDefaultAsync(gr => gr.Id == id);
|
||||||
|
|
||||||
|
public async Task<List<Models.GoodsReceipt>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize) =>
|
||||||
|
await context.GoodsReceipts
|
||||||
|
.Include(gr => gr.Warehouse)
|
||||||
|
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
|
||||||
|
.Where(gr => gr.IsActive && (purchaseOrderId == null || gr.FK_PurchaseOrderId == purchaseOrderId))
|
||||||
|
.OrderByDescending(gr => gr.ReceivedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<int> GetCountAsync(Guid? purchaseOrderId) =>
|
||||||
|
await context.GoodsReceipts
|
||||||
|
.Where(gr => gr.IsActive && (purchaseOrderId == null || gr.FK_PurchaseOrderId == purchaseOrderId))
|
||||||
|
.CountAsync();
|
||||||
|
|
||||||
public async Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
public async Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
||||||
await context.GoodsReceipts
|
await context.GoodsReceipts
|
||||||
.Include(gr => gr.Warehouse)
|
.Include(gr => gr.Warehouse)
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ public class GrnService(
|
|||||||
new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty,
|
new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty,
|
||||||
gr.ReceivedAt, gr.CreatedAt, gr.Items.Where(i => i.IsActive).Select(MapItem).ToList());
|
gr.ReceivedAt, gr.CreatedAt, gr.Items.Where(i => i.IsActive).Select(MapItem).ToList());
|
||||||
|
|
||||||
|
public async Task<PagedResponse<GrnResponse>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize)
|
||||||
|
{
|
||||||
|
var total = await repo.GetCountAsync(purchaseOrderId);
|
||||||
|
var items = await repo.GetAllAsync(purchaseOrderId, page, pageSize);
|
||||||
|
return new PagedResponse<GrnResponse>(page, pageSize, total, items.Select(Map).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<GrnResponse?> GetByIdAsync(Guid id)
|
public async Task<GrnResponse?> GetByIdAsync(Guid id)
|
||||||
{
|
{
|
||||||
var gr = await repo.GetByIdAsync(id);
|
var gr = await repo.GetByIdAsync(id);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ namespace Distribution.Features.Grn;
|
|||||||
public interface IGrnRepository
|
public interface IGrnRepository
|
||||||
{
|
{
|
||||||
Task<Models.GoodsReceipt?> GetByIdAsync(Guid id);
|
Task<Models.GoodsReceipt?> GetByIdAsync(Guid id);
|
||||||
|
Task<List<Models.GoodsReceipt>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize);
|
||||||
|
Task<int> GetCountAsync(Guid? purchaseOrderId);
|
||||||
Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId);
|
Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId);
|
||||||
Task AddAsync(Models.GoodsReceipt entity);
|
Task AddAsync(Models.GoodsReceipt entity);
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Item;
|
namespace Distribution.Features.Item;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/items")]
|
[Route("api/items")]
|
||||||
[Authorize]
|
|
||||||
public class ItemController(ItemService service) : ControllerBase
|
public class ItemController(ItemService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "items.view")]
|
|
||||||
public async Task<IActionResult> GetAll(
|
public async Task<IActionResult> GetAll(
|
||||||
[FromQuery] Guid? categoryId,
|
[FromQuery] Guid? categoryId,
|
||||||
[FromQuery] Guid? subCategoryId,
|
[FromQuery] Guid? subCategoryId,
|
||||||
@@ -17,7 +14,6 @@ public class ItemController(ItemService service) : ControllerBase
|
|||||||
Ok(await service.GetAllAsync(categoryId, subCategoryId, brandId));
|
Ok(await service.GetAllAsync(categoryId, subCategoryId, brandId));
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "items.view")]
|
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -25,7 +21,6 @@ public class ItemController(ItemService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "items.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateItemRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateItemRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -40,7 +35,6 @@ public class ItemController(ItemService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
[Authorize(Policy = "items.update")]
|
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateItemRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateItemRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateAsync(id, request, ct);
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
@@ -48,7 +42,6 @@ public class ItemController(ItemService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
[Authorize(Policy = "items.delete")]
|
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var deleted = await service.DeleteAsync(id, ct);
|
var deleted = await service.DeleteAsync(id, ct);
|
||||||
@@ -56,7 +49,6 @@ public class ItemController(ItemService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}/uom-conversions")]
|
[HttpPut("{id:guid}/uom-conversions")]
|
||||||
[Authorize(Policy = "items.update")]
|
|
||||||
public async Task<IActionResult> ReplaceConversions(Guid id, ReplaceConversionsRequest request, CancellationToken ct)
|
public async Task<IActionResult> ReplaceConversions(Guid id, ReplaceConversionsRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.ReplaceConversionsAsync(id, request, ct);
|
var result = await service.ReplaceConversionsAsync(id, request, ct);
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Distribution.Features.LoadingEvent;
|
namespace Distribution.Features.LoadingEvent;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/loading-sheets")]
|
[Route("api/loading-sheets")]
|
||||||
[Authorize]
|
|
||||||
public class LoadingSheetController(LoadingSheetService service, ICurrentUserService currentUser) : ControllerBase
|
public class LoadingSheetController(LoadingSheetService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "loading.view")]
|
|
||||||
public async Task<IActionResult> GetAll(
|
public async Task<IActionResult> GetAll(
|
||||||
[FromQuery] Guid? rideId,
|
[FromQuery] Guid? rideId,
|
||||||
[FromQuery] LoadingSheetStatus? status,
|
[FromQuery] LoadingSheetStatus? status,
|
||||||
@@ -23,7 +20,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "loading.view")]
|
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -31,7 +27,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "loading.dispatch")]
|
|
||||||
public async Task<IActionResult> Create(CreateLoadingSheetRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateLoadingSheetRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -44,7 +39,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}/items")]
|
[HttpPut("{id:guid}/items")]
|
||||||
[Authorize(Policy = "loading.dispatch")]
|
|
||||||
public async Task<IActionResult> SetItems(Guid id, SetItemsRequest request, CancellationToken ct)
|
public async Task<IActionResult> SetItems(Guid id, SetItemsRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -57,7 +51,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/confirm-dispatch")]
|
[HttpPost("{id:guid}/confirm-dispatch")]
|
||||||
[Authorize(Policy = "loading.dispatch")]
|
|
||||||
public async Task<IActionResult> ConfirmDispatch(Guid id, CancellationToken ct)
|
public async Task<IActionResult> ConfirmDispatch(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
@@ -71,7 +64,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}/receipt-items")]
|
[HttpPut("{id:guid}/receipt-items")]
|
||||||
[Authorize(Policy = "loading.receive")]
|
|
||||||
public async Task<IActionResult> SetReceiptItems(Guid id, SetReceiptItemsRequest request, CancellationToken ct)
|
public async Task<IActionResult> SetReceiptItems(Guid id, SetReceiptItemsRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -84,7 +76,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/confirm-receipt")]
|
[HttpPost("{id:guid}/confirm-receipt")]
|
||||||
[Authorize(Policy = "loading.receive")]
|
|
||||||
public async Task<IActionResult> ConfirmReceipt(Guid id, ConfirmReceiptRequest request, CancellationToken ct)
|
public async Task<IActionResult> ConfirmReceipt(Guid id, ConfirmReceiptRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
@@ -98,7 +89,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/resolve")]
|
[HttpPost("{id:guid}/resolve")]
|
||||||
[Authorize(Policy = "loading.resolve")]
|
|
||||||
public async Task<IActionResult> Resolve(Guid id, ResolveDiscrepancyRequest request, CancellationToken ct)
|
public async Task<IActionResult> Resolve(Guid id, ResolveDiscrepancyRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
@@ -110,4 +100,43 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
|||||||
catch (KeyNotFoundException) { return NotFound(); }
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{id:guid}/dispatch")]
|
||||||
|
public async Task<IActionResult> PatchDispatch(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.ConfirmDispatchAsync(id, userId, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{id:guid}/receipt")]
|
||||||
|
public async Task<IActionResult> PatchReceipt(Guid id, PatchReceiptRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.PatchReceiptAsync(id, request.Items, userId, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{id:guid}/resolve")]
|
||||||
|
public async Task<IActionResult> PatchResolve(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.PatchResolveAsync(id, userId, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,3 +35,7 @@ public record ConfirmReceiptRequest(int Meterstart);
|
|||||||
|
|
||||||
public record ResolveDiscrepancyRequest(List<ResolveItemInput> Items, int Meterstart);
|
public record ResolveDiscrepancyRequest(List<ResolveItemInput> Items, int Meterstart);
|
||||||
public record ResolveItemInput(Guid FK_ItemId, decimal QuantityResolved);
|
public record ResolveItemInput(Guid FK_ItemId, decimal QuantityResolved);
|
||||||
|
|
||||||
|
// Combined PATCH endpoints used by the frontend
|
||||||
|
public record PatchReceiptItemInput(Guid ItemId, decimal QuantityReceived);
|
||||||
|
public record PatchReceiptRequest(List<PatchReceiptItemInput> Items);
|
||||||
|
|||||||
@@ -225,6 +225,98 @@ public class LoadingSheetService(
|
|||||||
return ToResponse(sheet);
|
return ToResponse(sheet);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<LoadingSheetResponse> PatchReceiptAsync(Guid id, List<PatchReceiptItemInput> items, Guid userId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var sheet = await repo.GetByIdAsync(id)
|
||||||
|
?? throw new KeyNotFoundException("Loading sheet not found");
|
||||||
|
|
||||||
|
if (sheet.Status != LoadingSheetStatus.DispatchConfirmed)
|
||||||
|
throw new InvalidOperationException($"Cannot set receipt for a sheet in {sheet.Status} status");
|
||||||
|
|
||||||
|
foreach (var input in items)
|
||||||
|
{
|
||||||
|
var sheetItem = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
|
||||||
|
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this loading sheet");
|
||||||
|
sheetItem.QuantityReceived = input.QuantityReceived;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
|
||||||
|
?? throw new KeyNotFoundException("Ride not found");
|
||||||
|
|
||||||
|
var hasDiscrepancy = sheet.Items.Any(i => i.QuantityReceived != i.QuantityDispatched);
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (hasDiscrepancy)
|
||||||
|
{
|
||||||
|
sheet.Status = LoadingSheetStatus.DiscrepancyFlagged;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sheet.Status = LoadingSheetStatus.ReceiptConfirmed;
|
||||||
|
sheet.ReceiptConfirmedAt = DateTime.Now;
|
||||||
|
sheet.ReceiptConfirmedBy = userId;
|
||||||
|
|
||||||
|
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityReceived!.Value)).ToList();
|
||||||
|
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
|
||||||
|
|
||||||
|
ride.Status = RideStatus.Active;
|
||||||
|
ride.ActivatedAt = DateTime.Now;
|
||||||
|
ride.Meterstart = 0;
|
||||||
|
rideRepo.Update(ride);
|
||||||
|
}
|
||||||
|
|
||||||
|
repo.Update(sheet);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return ToResponse(sheet);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LoadingSheetResponse> PatchResolveAsync(Guid id, Guid userId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var sheet = await repo.GetByIdAsync(id)
|
||||||
|
?? throw new KeyNotFoundException("Loading sheet not found");
|
||||||
|
|
||||||
|
if (sheet.Status != LoadingSheetStatus.DiscrepancyFlagged)
|
||||||
|
throw new InvalidOperationException($"Cannot resolve a sheet in {sheet.Status} status");
|
||||||
|
|
||||||
|
foreach (var item in sheet.Items)
|
||||||
|
{
|
||||||
|
if (!item.QuantityReceived.HasValue)
|
||||||
|
throw new InvalidOperationException($"Item {item.FK_ItemId} has no received quantity to resolve from");
|
||||||
|
item.QuantityResolved = item.QuantityReceived.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
|
||||||
|
?? throw new KeyNotFoundException("Ride not found");
|
||||||
|
|
||||||
|
sheet.Status = LoadingSheetStatus.Resolved;
|
||||||
|
sheet.ReceiptConfirmedAt = DateTime.Now;
|
||||||
|
sheet.ReceiptConfirmedBy = userId;
|
||||||
|
|
||||||
|
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityResolved!.Value)).ToList();
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
|
||||||
|
|
||||||
|
ride.Status = RideStatus.Active;
|
||||||
|
ride.ActivatedAt = DateTime.Now;
|
||||||
|
ride.Meterstart = 0;
|
||||||
|
rideRepo.Update(ride);
|
||||||
|
|
||||||
|
repo.Update(sheet);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return ToResponse(sheet);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task ApplyLoadMovementsAsync(LoadingSheet sheet, Guid vehicleId, List<(Guid ItemId, decimal Qty)> lines, Guid userId)
|
private async Task ApplyLoadMovementsAsync(LoadingSheet sheet, Guid vehicleId, List<(Guid ItemId, decimal Qty)> lines, Guid userId)
|
||||||
{
|
{
|
||||||
foreach (var (itemId, qty) in lines.Where(l => l.Qty > 0))
|
foreach (var (itemId, qty) in lines.Where(l => l.Qty > 0))
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Order;
|
namespace Distribution.Features.Order;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/orders")]
|
[Route("api/orders")]
|
||||||
[Authorize]
|
|
||||||
public class OrderController(OrderService service) : ControllerBase
|
public class OrderController(OrderService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "orders.view")]
|
|
||||||
public async Task<IActionResult> GetAll() =>
|
public async Task<IActionResult> GetAll() =>
|
||||||
Ok(await service.GetAllAsync());
|
Ok(await service.GetAllAsync());
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "orders.view")]
|
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -22,7 +18,6 @@ public class OrderController(OrderService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("store/{storeId:guid}")]
|
[HttpGet("store/{storeId:guid}")]
|
||||||
[Authorize(Policy = "orders.view")]
|
|
||||||
public async Task<IActionResult> GetByStore(Guid storeId)
|
public async Task<IActionResult> GetByStore(Guid storeId)
|
||||||
{
|
{
|
||||||
var result = await service.GetByStoreIdAsync(storeId);
|
var result = await service.GetByStoreIdAsync(storeId);
|
||||||
@@ -30,7 +25,6 @@ public class OrderController(OrderService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "orders.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateOrderRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateOrderRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -42,7 +36,6 @@ public class OrderController(OrderService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
[Authorize(Policy = "orders.update")]
|
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateOrderRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateOrderRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -54,7 +47,6 @@ public class OrderController(OrderService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
[Authorize(Policy = "orders.delete")]
|
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var deleted = await service.DeleteAsync(id, ct);
|
var deleted = await service.DeleteAsync(id, ct);
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Payment;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/payments")]
|
||||||
|
public class FlatPaymentController(PaymentService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetAll(
|
||||||
|
[FromQuery] Guid? storeId,
|
||||||
|
[FromQuery] Guid? saleId,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20)
|
||||||
|
{
|
||||||
|
var result = await service.GetAllAsync(storeId, saleId, page, pageSize);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
|
{
|
||||||
|
var result = await service.GetByIdAsync(id);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create(CreateFlatPaymentRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var inner = new CreatePaymentRequest(request.Amount, request.Method);
|
||||||
|
var result = await service.CreateAsync(request.SaleId, inner, ct);
|
||||||
|
return result is null ? NotFound(new { error = "Sale not found or access denied" }) : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ namespace Distribution.Features.Payment;
|
|||||||
|
|
||||||
public interface IPaymentRepository
|
public interface IPaymentRepository
|
||||||
{
|
{
|
||||||
|
Task<Models.Payment?> GetByIdAsync(Guid id);
|
||||||
|
Task<List<Models.Payment>> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize);
|
||||||
|
Task<int> GetCountAsync(Guid? storeId, Guid? saleId);
|
||||||
Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId);
|
Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId);
|
||||||
Task AddAsync(Models.Payment payment);
|
Task AddAsync(Models.Payment payment);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Payment;
|
namespace Distribution.Features.Payment;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
|
||||||
[Route("api/sales/{saleId:guid}/payments")]
|
[Route("api/sales/{saleId:guid}/payments")]
|
||||||
public class PaymentController(PaymentService service) : ControllerBase
|
public class PaymentController(PaymentService service) : ControllerBase
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ namespace Distribution.Features.Payment;
|
|||||||
|
|
||||||
public record CreatePaymentRequest(decimal Amount, PaymentMethod Method);
|
public record CreatePaymentRequest(decimal Amount, PaymentMethod Method);
|
||||||
|
|
||||||
|
public record CreateFlatPaymentRequest(Guid SaleId, decimal Amount, PaymentMethod Method);
|
||||||
|
|
||||||
public record PaymentResponse(
|
public record PaymentResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid SaleId,
|
Guid SaleId,
|
||||||
|
|||||||
@@ -6,6 +6,26 @@ namespace Distribution.Features.Payment;
|
|||||||
|
|
||||||
public class PaymentRepository(AppDBContext context) : IPaymentRepository
|
public class PaymentRepository(AppDBContext context) : IPaymentRepository
|
||||||
{
|
{
|
||||||
|
public async Task<Models.Payment?> GetByIdAsync(Guid id) =>
|
||||||
|
await context.Payments.FirstOrDefaultAsync(p => p.Id == id && p.IsActive);
|
||||||
|
|
||||||
|
public async Task<List<Models.Payment>> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize) =>
|
||||||
|
await context.Payments
|
||||||
|
.Where(p => p.IsActive
|
||||||
|
&& (storeId == null || p.FK_StoreId == storeId)
|
||||||
|
&& (saleId == null || p.FK_SaleId == saleId))
|
||||||
|
.OrderByDescending(p => p.CollectedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<int> GetCountAsync(Guid? storeId, Guid? saleId) =>
|
||||||
|
await context.Payments
|
||||||
|
.Where(p => p.IsActive
|
||||||
|
&& (storeId == null || p.FK_StoreId == storeId)
|
||||||
|
&& (saleId == null || p.FK_SaleId == saleId))
|
||||||
|
.CountAsync();
|
||||||
|
|
||||||
public async Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId) =>
|
public async Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId) =>
|
||||||
await context.Payments
|
await context.Payments
|
||||||
.Where(p => p.FK_SaleId == saleId && p.IsActive)
|
.Where(p => p.FK_SaleId == saleId && p.IsActive)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Distribution.Features.Sale;
|
using Distribution.Features.Sale;
|
||||||
|
using Distribution.Features.Stock;
|
||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
@@ -14,6 +15,19 @@ public class PaymentService(
|
|||||||
private static PaymentResponse ToResponse(Models.Payment p) =>
|
private static PaymentResponse ToResponse(Models.Payment p) =>
|
||||||
new(p.Id, p.FK_SaleId, p.FK_StoreId, p.Amount, p.Method, p.CollectedAt, p.CollectedBy, p.CreatedAt);
|
new(p.Id, p.FK_SaleId, p.FK_StoreId, p.Amount, p.Method, p.CollectedAt, p.CollectedBy, p.CreatedAt);
|
||||||
|
|
||||||
|
public async Task<PagedResponse<PaymentResponse>> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize)
|
||||||
|
{
|
||||||
|
var total = await repo.GetCountAsync(storeId, saleId);
|
||||||
|
var items = await repo.GetAllAsync(storeId, saleId, page, pageSize);
|
||||||
|
return new PagedResponse<PaymentResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PaymentResponse?> GetByIdAsync(Guid id)
|
||||||
|
{
|
||||||
|
var payment = await repo.GetByIdAsync(id);
|
||||||
|
return payment is null ? null : ToResponse(payment);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<PaymentResponse>?> GetBySaleIdAsync(Guid saleId)
|
public async Task<List<PaymentResponse>?> GetBySaleIdAsync(Guid saleId)
|
||||||
{
|
{
|
||||||
var sale = await saleRepo.GetByIdAsync(saleId);
|
var sale = await saleRepo.GetByIdAsync(saleId);
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.PriceList;
|
namespace Distribution.Features.PriceList;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/price-lists")]
|
[Route("api/price-lists")]
|
||||||
[Authorize]
|
|
||||||
public class PriceListController(PriceListService service) : ControllerBase
|
public class PriceListController(PriceListService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Distribution.Features.Po;
|
namespace Distribution.Features.Po;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/purchase-orders")]
|
[Route("api/purchase-orders")]
|
||||||
[Authorize]
|
|
||||||
public class PoController(PoService service, ICurrentUserService currentUser) : ControllerBase
|
public class PoController(PoService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Distribution.Features.PurchaseReturn;
|
namespace Distribution.Features.PurchaseReturn;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
|
||||||
public class PurchaseReturnController(PurchaseReturnService service, ICurrentUserService currentUser) : ControllerBase
|
public class PurchaseReturnController(PurchaseReturnService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("api/purchase-returns/{id:guid}")]
|
[HttpGet("api/purchase-returns/{id:guid}")]
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Region;
|
namespace Distribution.Features.Region;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/regions")]
|
[Route("api/regions")]
|
||||||
[Authorize]
|
|
||||||
public class RegionController(RegionService service) : ControllerBase
|
public class RegionController(RegionService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "regions.view")]
|
|
||||||
public async Task<IActionResult> GetAll() =>
|
public async Task<IActionResult> GetAll() =>
|
||||||
Ok(await service.GetAllAsync());
|
Ok(await service.GetAllAsync());
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "regions.view")]
|
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -22,7 +18,6 @@ public class RegionController(RegionService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "regions.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateRegionRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateRegionRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
var result = await service.CreateAsync(request, ct);
|
||||||
@@ -30,7 +25,6 @@ public class RegionController(RegionService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
[Authorize(Policy = "regions.update")]
|
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateRegionRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateRegionRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateAsync(id, request, ct);
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
@@ -38,7 +32,6 @@ public class RegionController(RegionService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
[Authorize(Policy = "regions.delete")]
|
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var deleted = await service.DeleteAsync(id, ct);
|
var deleted = await service.DeleteAsync(id, ct);
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Distribution.Features.Returns;
|
namespace Distribution.Features.Returns;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
|
||||||
public class ReturnController(ReturnService service, ICurrentUserService currentUser) : ControllerBase
|
public class ReturnController(ReturnService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("api/returns/{id:guid}")]
|
[HttpGet("api/returns/{id:guid}")]
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Distribution.Features.Ride;
|
namespace Distribution.Features.Ride;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/rides")]
|
[Route("api/rides")]
|
||||||
[Authorize]
|
|
||||||
public class RideController(RideService service, ICurrentUserService currentUser) : ControllerBase
|
public class RideController(RideService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "rides.view")]
|
|
||||||
public async Task<IActionResult> GetAll(
|
public async Task<IActionResult> GetAll(
|
||||||
[FromQuery] RideStatus? status,
|
[FromQuery] RideStatus? status,
|
||||||
[FromQuery] RideType? type,
|
[FromQuery] RideType? type,
|
||||||
@@ -25,7 +22,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "rides.view")]
|
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -33,7 +29,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "rides.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateRideRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateRideRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
var result = await service.CreateAsync(request, ct);
|
||||||
@@ -41,7 +36,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}/driver")]
|
[HttpPut("{id:guid}/driver")]
|
||||||
[Authorize(Policy = "rides.manage")]
|
|
||||||
public async Task<IActionResult> AssignDriver(Guid id, AssignRideDriverRequest request, CancellationToken ct)
|
public async Task<IActionResult> AssignDriver(Guid id, AssignRideDriverRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -54,7 +48,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/open")]
|
[HttpPost("{id:guid}/open")]
|
||||||
[Authorize(Policy = "rides.manage")]
|
|
||||||
public async Task<IActionResult> Open(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Open(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -66,8 +59,8 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{id:guid}/activate")]
|
||||||
[HttpPost("{id:guid}/activate")]
|
[HttpPost("{id:guid}/activate")]
|
||||||
[Authorize(Policy = "rides.manage")]
|
|
||||||
public async Task<IActionResult> Activate(Guid id, ActivateRideRequest request, CancellationToken ct)
|
public async Task<IActionResult> Activate(Guid id, ActivateRideRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -79,8 +72,8 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{id:guid}/close")]
|
||||||
[HttpPost("{id:guid}/close")]
|
[HttpPost("{id:guid}/close")]
|
||||||
[Authorize(Policy = "rides.manage")]
|
|
||||||
public async Task<IActionResult> Close(Guid id, CloseRideRequest request, CancellationToken ct)
|
public async Task<IActionResult> Close(Guid id, CloseRideRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -93,7 +86,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/cancel-request")]
|
[HttpPost("{id:guid}/cancel-request")]
|
||||||
[Authorize(Policy = "rides.cancel.request")]
|
|
||||||
public async Task<IActionResult> RaiseCancelRequest(Guid id, RaiseCancelRequest request, CancellationToken ct)
|
public async Task<IActionResult> RaiseCancelRequest(Guid id, RaiseCancelRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
@@ -107,7 +99,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("cancel-requests")]
|
[HttpGet("cancel-requests")]
|
||||||
[Authorize(Policy = "rides.view")]
|
|
||||||
public async Task<IActionResult> GetCancelRequests(
|
public async Task<IActionResult> GetCancelRequests(
|
||||||
[FromQuery] CancelRequestStatus? status,
|
[FromQuery] CancelRequestStatus? status,
|
||||||
[FromQuery] int page = 1,
|
[FromQuery] int page = 1,
|
||||||
@@ -118,7 +109,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("cancel-requests/{id:guid}/approve")]
|
[HttpPost("cancel-requests/{id:guid}/approve")]
|
||||||
[Authorize(Policy = "rides.cancel.approve")]
|
|
||||||
public async Task<IActionResult> ApproveCancelRequest(Guid id, CancellationToken ct)
|
public async Task<IActionResult> ApproveCancelRequest(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
@@ -132,7 +122,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("cancel-requests/{id:guid}/reject")]
|
[HttpPost("cancel-requests/{id:guid}/reject")]
|
||||||
[Authorize(Policy = "rides.cancel.approve")]
|
|
||||||
public async Task<IActionResult> RejectCancelRequest(Guid id, CancellationToken ct)
|
public async Task<IActionResult> RejectCancelRequest(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
@@ -146,7 +135,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}/cancel-request")]
|
[HttpGet("{id:guid}/cancel-request")]
|
||||||
[Authorize(Policy = "rides.view")]
|
|
||||||
public async Task<IActionResult> GetCancelRequest(Guid id)
|
public async Task<IActionResult> GetCancelRequest(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetCancelRequestAsync(id);
|
var result = await service.GetCancelRequestAsync(id);
|
||||||
@@ -154,7 +142,6 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("breakdown-assist/{rideId:guid}")]
|
[HttpGet("breakdown-assist/{rideId:guid}")]
|
||||||
[Authorize(Policy = "rides.view")]
|
|
||||||
public async Task<IActionResult> GetBreakdownAssist(Guid rideId)
|
public async Task<IActionResult> GetBreakdownAssist(Guid rideId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Role;
|
namespace Distribution.Features.Role;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/roles")]
|
[Route("api/roles")]
|
||||||
[Authorize]
|
|
||||||
public class RoleController(RoleService service) : ControllerBase
|
public class RoleController(RoleService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Route;
|
namespace Distribution.Features.Route;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/routes")]
|
[Route("api/routes")]
|
||||||
[Authorize]
|
|
||||||
public class RouteController(RouteService service) : ControllerBase
|
public class RouteController(RouteService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "routes.view")]
|
|
||||||
public async Task<IActionResult> GetAll() =>
|
public async Task<IActionResult> GetAll() =>
|
||||||
Ok(await service.GetAllAsync());
|
Ok(await service.GetAllAsync());
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "routes.view")]
|
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -22,7 +18,6 @@ public class RouteController(RouteService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "routes.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateRouteRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateRouteRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
var result = await service.CreateAsync(request, ct);
|
||||||
@@ -30,7 +25,6 @@ public class RouteController(RouteService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
[Authorize(Policy = "routes.update")]
|
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateRouteRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateRouteRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateAsync(id, request, ct);
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
@@ -38,7 +32,6 @@ public class RouteController(RouteService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
[Authorize(Policy = "routes.delete")]
|
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var deleted = await service.DeleteAsync(id, ct);
|
var deleted = await service.DeleteAsync(id, ct);
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ namespace Distribution.Features.Sale;
|
|||||||
public interface ISaleRepository
|
public interface ISaleRepository
|
||||||
{
|
{
|
||||||
Task<Models.Sale?> GetByIdAsync(Guid id);
|
Task<Models.Sale?> GetByIdAsync(Guid id);
|
||||||
Task<List<Models.Sale>> GetAllAsync();
|
Task<List<Models.Sale>> GetAllAsync(Guid? rideId, IEnumerable<Guid>? storeIds, int page, int pageSize);
|
||||||
|
Task<int> GetCountAsync(Guid? rideId, IEnumerable<Guid>? storeIds);
|
||||||
Task<List<Models.Sale>> GetByStoreIdAsync(Guid storeId);
|
Task<List<Models.Sale>> GetByStoreIdAsync(Guid storeId);
|
||||||
Task AddAsync(Models.Sale sale);
|
Task AddAsync(Models.Sale sale);
|
||||||
void Update(Models.Sale sale);
|
void Update(Models.Sale sale);
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Sale;
|
namespace Distribution.Features.Sale;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/sales")]
|
[Route("api/sales")]
|
||||||
[Authorize]
|
|
||||||
public class SaleController(SaleService service) : ControllerBase
|
public class SaleController(SaleService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "orders.view")]
|
public async Task<IActionResult> GetAll(
|
||||||
public async Task<IActionResult> GetAll() =>
|
[FromQuery] Guid? rideId,
|
||||||
Ok(await service.GetAllAsync());
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20) =>
|
||||||
|
Ok(await service.GetAllAsync(rideId, page, pageSize));
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "orders.view")]
|
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -22,7 +21,6 @@ public class SaleController(SaleService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("store/{storeId:guid}")]
|
[HttpGet("store/{storeId:guid}")]
|
||||||
[Authorize(Policy = "orders.view")]
|
|
||||||
public async Task<IActionResult> GetByStore(Guid storeId)
|
public async Task<IActionResult> GetByStore(Guid storeId)
|
||||||
{
|
{
|
||||||
var result = await service.GetByStoreIdAsync(storeId);
|
var result = await service.GetByStoreIdAsync(storeId);
|
||||||
@@ -30,10 +28,20 @@ public class SaleController(SaleService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "orders.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateSaleRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateSaleRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
var result = await service.CreateAsync(request, ct);
|
||||||
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{id:guid}/cancel")]
|
||||||
|
public async Task<IActionResult> Cancel(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.CancelAsync(id, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,21 @@ public class SaleRepository(AppDBContext context) : ISaleRepository
|
|||||||
public async Task<Models.Sale?> GetByIdAsync(Guid id) =>
|
public async Task<Models.Sale?> GetByIdAsync(Guid id) =>
|
||||||
await context.Sales.FindAsync(id);
|
await context.Sales.FindAsync(id);
|
||||||
|
|
||||||
public async Task<List<Models.Sale>> GetAllAsync() =>
|
public async Task<List<Models.Sale>> GetAllAsync(Guid? rideId, IEnumerable<Guid>? storeIds, int page, int pageSize) =>
|
||||||
await context.Sales.ToListAsync();
|
await context.Sales
|
||||||
|
.Where(s => s.IsActive &&
|
||||||
|
(rideId == null || s.FK_RideId == rideId) &&
|
||||||
|
(storeIds == null || storeIds.Contains(s.FK_StoreId)))
|
||||||
|
.OrderByDescending(s => s.CreatedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<int> GetCountAsync(Guid? rideId, IEnumerable<Guid>? storeIds) =>
|
||||||
|
await context.Sales
|
||||||
|
.CountAsync(s => s.IsActive &&
|
||||||
|
(rideId == null || s.FK_RideId == rideId) &&
|
||||||
|
(storeIds == null || storeIds.Contains(s.FK_StoreId)));
|
||||||
|
|
||||||
public async Task<List<Models.Sale>> GetByStoreIdAsync(Guid storeId) =>
|
public async Task<List<Models.Sale>> GetByStoreIdAsync(Guid storeId) =>
|
||||||
await context.Sales.Where(s => s.FK_StoreId == storeId && s.IsActive).ToListAsync();
|
await context.Sales.Where(s => s.FK_StoreId == storeId && s.IsActive).ToListAsync();
|
||||||
|
|||||||
@@ -71,14 +71,14 @@ public class SaleService(
|
|||||||
sale.IsActive,
|
sale.IsActive,
|
||||||
sale.CreatedAt);
|
sale.CreatedAt);
|
||||||
|
|
||||||
public async Task<List<SaleResponse>> GetAllAsync()
|
public async Task<PagedResponse<SaleResponse>> GetAllAsync(Guid? rideId, int page, int pageSize)
|
||||||
{
|
{
|
||||||
var sales = (await repo.GetAllAsync()).Where(s => s.IsActive).ToList();
|
IEnumerable<Guid>? storeIds = null;
|
||||||
if (!HasGlobalScope && currentUser.UserId is not null)
|
if (!HasGlobalScope && currentUser.UserId is not null)
|
||||||
{
|
storeIds = await scopeAccess.GetAccessibleStoreIdsAsync(currentUser.UserId.Value);
|
||||||
var accessibleStoreIds = (await scopeAccess.GetAccessibleStoreIdsAsync(currentUser.UserId.Value)).ToHashSet();
|
|
||||||
sales = sales.Where(s => accessibleStoreIds.Contains(s.FK_StoreId)).ToList();
|
var total = await repo.GetCountAsync(rideId, storeIds);
|
||||||
}
|
var sales = await repo.GetAllAsync(rideId, storeIds, page, pageSize);
|
||||||
|
|
||||||
var result = new List<SaleResponse>();
|
var result = new List<SaleResponse>();
|
||||||
foreach (var sale in sales)
|
foreach (var sale in sales)
|
||||||
@@ -88,7 +88,7 @@ public class SaleService(
|
|||||||
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
|
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
|
||||||
result.Add(ToResponse(sale, order, items));
|
result.Add(ToResponse(sale, order, items));
|
||||||
}
|
}
|
||||||
return result;
|
return new PagedResponse<SaleResponse>(page, pageSize, total, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SaleResponse?> GetByIdAsync(Guid id)
|
public async Task<SaleResponse?> GetByIdAsync(Guid id)
|
||||||
@@ -119,6 +119,26 @@ public class SaleService(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<SaleResponse?> CancelAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var sale = await repo.GetByIdAsync(id);
|
||||||
|
if (sale is null || !sale.IsActive) return null;
|
||||||
|
if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null;
|
||||||
|
if (sale.Status == SaleStatus.Cancelled)
|
||||||
|
throw new InvalidOperationException("Sale is already cancelled");
|
||||||
|
|
||||||
|
sale.Status = SaleStatus.Cancelled;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(sale); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
var order = await repo.GetOrderBySaleIdAsync(id);
|
||||||
|
if (order is null) return null;
|
||||||
|
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
|
||||||
|
return ToResponse(sale, order, items);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<SaleResponse?> CreateAsync(CreateSaleRequest request, CancellationToken ct = default)
|
public async Task<SaleResponse?> CreateAsync(CreateSaleRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (!await HasStoreScopeAsync(request.StoreId)) return null;
|
if (!await HasStoreScopeAsync(request.StoreId)) return null;
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Scheme;
|
namespace Distribution.Features.Scheme;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/schemes")]
|
[Route("api/schemes")]
|
||||||
[Authorize]
|
|
||||||
public class SchemeController(SchemeService service) : ControllerBase
|
public class SchemeController(SchemeService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@@ -120,7 +118,6 @@ public class SchemeController(SchemeService service) : ControllerBase
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/orders")]
|
[Route("api/orders")]
|
||||||
[Authorize]
|
|
||||||
public class SchemeOrderController(SchemeService service) : ControllerBase
|
public class SchemeOrderController(SchemeService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpPost("{id:guid}/apply-scheme")]
|
[HttpPost("{id:guid}/apply-scheme")]
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Distribution.Features.Stock;
|
namespace Distribution.Features.Stock;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/stock")]
|
[Route("api/stock")]
|
||||||
[Authorize]
|
|
||||||
public class StockController(StockService service, ICurrentUserService currentUser) : ControllerBase
|
public class StockController(StockService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
{
|
{
|
||||||
// ── Query ──────────────────────────────────────────────────────────────
|
// ── Query ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[HttpGet("balances")]
|
[HttpGet("balances")]
|
||||||
public async Task<IActionResult> GetBalances(
|
public async Task<IActionResult> GetBalances(
|
||||||
@@ -46,7 +44,7 @@ public class StockController(StockService service, ICurrentUserService currentUs
|
|||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Write ──────────────────────────────────────────────────────────────
|
// ── Write ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[HttpPost("transfer")]
|
[HttpPost("transfer")]
|
||||||
public async Task<IActionResult> Transfer(TransferRequest request, CancellationToken ct)
|
public async Task<IActionResult> Transfer(TransferRequest request, CancellationToken ct)
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Store.Kyc;
|
namespace Distribution.Features.Store.Kyc;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/stores/{storeId:guid}/kyc-documents")]
|
[Route("api/stores/{storeId:guid}/kyc-documents")]
|
||||||
[Authorize]
|
|
||||||
public class StoreKycDocumentController(StoreKycDocumentService service) : ControllerBase
|
public class StoreKycDocumentController(StoreKycDocumentService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "stores.view")]
|
|
||||||
public async Task<IActionResult> GetByStoreId(Guid storeId)
|
public async Task<IActionResult> GetByStoreId(Guid storeId)
|
||||||
{
|
{
|
||||||
var result = await service.GetByStoreIdAsync(storeId);
|
var result = await service.GetByStoreIdAsync(storeId);
|
||||||
@@ -17,7 +14,6 @@ public class StoreKycDocumentController(StoreKycDocumentService service) : Contr
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "stores.update")]
|
|
||||||
public async Task<IActionResult> Upload(Guid storeId, [FromForm] UploadKycDocumentRequest request, CancellationToken ct)
|
public async Task<IActionResult> Upload(Guid storeId, [FromForm] UploadKycDocumentRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UploadAsync(storeId, request, ct);
|
var result = await service.UploadAsync(storeId, request, ct);
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Store;
|
namespace Distribution.Features.Store;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/stores")]
|
[Route("api/stores")]
|
||||||
[Authorize]
|
|
||||||
public class StoreController(StoreService service) : ControllerBase
|
public class StoreController(StoreService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "stores.view")]
|
|
||||||
public async Task<IActionResult> GetAll() =>
|
public async Task<IActionResult> GetAll() =>
|
||||||
Ok(await service.GetAllAsync());
|
Ok(await service.GetAllAsync());
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "stores.view")]
|
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -22,7 +18,6 @@ public class StoreController(StoreService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "stores.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateStoreRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateStoreRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
var result = await service.CreateAsync(request, ct);
|
||||||
@@ -30,7 +25,6 @@ public class StoreController(StoreService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
[Authorize(Policy = "stores.update")]
|
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateStoreRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateStoreRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateAsync(id, request, ct);
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
@@ -38,7 +32,6 @@ public class StoreController(StoreService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
[Authorize(Policy = "stores.delete")]
|
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var deleted = await service.DeleteAsync(id, ct);
|
var deleted = await service.DeleteAsync(id, ct);
|
||||||
@@ -46,7 +39,6 @@ public class StoreController(StoreService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}/credit-limit")]
|
[HttpPut("{id:guid}/credit-limit")]
|
||||||
[Authorize(Policy = "stores.credit.update")]
|
|
||||||
public async Task<IActionResult> UpdateCreditLimit(Guid id, UpdateCreditLimitRequest request, CancellationToken ct)
|
public async Task<IActionResult> UpdateCreditLimit(Guid id, UpdateCreditLimitRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateCreditLimitAsync(id, request, ct);
|
var result = await service.UpdateCreditLimitAsync(id, request, ct);
|
||||||
@@ -54,7 +46,6 @@ public class StoreController(StoreService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}/outstanding-balance")]
|
[HttpPut("{id:guid}/outstanding-balance")]
|
||||||
[Authorize(Policy = "stores.credit.update")]
|
|
||||||
public async Task<IActionResult> UpdateOutstandingBalance(Guid id, UpdateOutstandingBalanceRequest request, CancellationToken ct)
|
public async Task<IActionResult> UpdateOutstandingBalance(Guid id, UpdateOutstandingBalanceRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateOutstandingBalanceAsync(id, request, ct);
|
var result = await service.UpdateOutstandingBalanceAsync(id, request, ct);
|
||||||
@@ -62,7 +53,6 @@ public class StoreController(StoreService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}/outstanding-balance")]
|
[HttpGet("{id:guid}/outstanding-balance")]
|
||||||
[Authorize(Policy = "stores.view")]
|
|
||||||
public async Task<IActionResult> GetOutstandingBalance(Guid id)
|
public async Task<IActionResult> GetOutstandingBalance(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetOutstandingBalanceAsync(id);
|
var result = await service.GetOutstandingBalanceAsync(id);
|
||||||
@@ -70,7 +60,6 @@ public class StoreController(StoreService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}/kyc/verify")]
|
[HttpPut("{id:guid}/kyc/verify")]
|
||||||
[Authorize(Policy = "stores.kyc.verify")]
|
|
||||||
public async Task<IActionResult> VerifyKyc(Guid id, VerifyKycRequest request, CancellationToken ct)
|
public async Task<IActionResult> VerifyKyc(Guid id, VerifyKycRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.VerifyKycAsync(id, request, ct);
|
var result = await service.VerifyKycAsync(id, request, ct);
|
||||||
@@ -78,7 +67,6 @@ public class StoreController(StoreService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}/segment")]
|
[HttpPut("{id:guid}/segment")]
|
||||||
[Authorize(Policy = "stores.update")]
|
|
||||||
public async Task<IActionResult> ClassifySegment(Guid id, ClassifySegmentRequest request, CancellationToken ct)
|
public async Task<IActionResult> ClassifySegment(Guid id, ClassifySegmentRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.ClassifySegmentAsync(id, request, ct);
|
var result = await service.ClassifySegmentAsync(id, request, ct);
|
||||||
@@ -86,7 +74,6 @@ public class StoreController(StoreService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}/visit-history")]
|
[HttpGet("{id:guid}/visit-history")]
|
||||||
[Authorize(Policy = "stores.view")]
|
|
||||||
public async Task<IActionResult> GetVisitHistory(Guid id)
|
public async Task<IActionResult> GetVisitHistory(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetVisitHistoryAsync(id);
|
var result = await service.GetVisitHistoryAsync(id);
|
||||||
@@ -94,7 +81,6 @@ public class StoreController(StoreService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}/schemes/{schemeId:guid}/eligibility")]
|
[HttpGet("{id:guid}/schemes/{schemeId:guid}/eligibility")]
|
||||||
[Authorize(Policy = "stores.view")]
|
|
||||||
public async Task<IActionResult> CheckSchemeEligibility(Guid id, Guid schemeId)
|
public async Task<IActionResult> CheckSchemeEligibility(Guid id, Guid schemeId)
|
||||||
{
|
{
|
||||||
var result = await service.CheckSchemeEligibilityAsync(id, schemeId);
|
var result = await service.CheckSchemeEligibilityAsync(id, schemeId);
|
||||||
@@ -102,10 +88,16 @@ public class StoreController(StoreService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}/dashboard")]
|
[HttpGet("{id:guid}/dashboard")]
|
||||||
[Authorize(Policy = "stores.view")]
|
|
||||||
public async Task<IActionResult> GetDashboard(Guid id, [FromQuery] string period)
|
public async Task<IActionResult> GetDashboard(Guid id, [FromQuery] string period)
|
||||||
{
|
{
|
||||||
var result = await service.GetDashboardAsync(id, period);
|
var result = await service.GetDashboardAsync(id, period);
|
||||||
return result is null ? NotFound() : Ok(result);
|
return result is null ? NotFound() : Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{id:guid}/kyc-status")]
|
||||||
|
public async Task<IActionResult> PatchKycStatus(Guid id, PatchKycStatusRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await service.VerifyKycAsync(id, new VerifyKycRequest(request.KycStatus, null), ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,4 +53,6 @@ public record StoreVisitHistoryItem(Guid VisitId, DateTime VisitDate, Guid RepUs
|
|||||||
|
|
||||||
public record SchemeEligibilityResponse(bool Eligible, string? Reason);
|
public record SchemeEligibilityResponse(bool Eligible, string? Reason);
|
||||||
|
|
||||||
|
public record PatchKycStatusRequest(KycStatus KycStatus);
|
||||||
|
|
||||||
public record StoreDashboardResponse(Guid StoreId, string Period, int OrderCount, decimal TotalOrderValue, DateTime? LastOrderDate);
|
public record StoreDashboardResponse(Guid StoreId, string Period, int OrderCount, decimal TotalOrderValue, DateTime? LastOrderDate);
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Supplier;
|
namespace Distribution.Features.Supplier;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/suppliers")]
|
[Route("api/suppliers")]
|
||||||
[Authorize]
|
|
||||||
public class SupplierController(SupplierService service) : ControllerBase
|
public class SupplierController(SupplierService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Distribution.Features.SupplierClaims;
|
namespace Distribution.Features.SupplierClaims;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
|
||||||
public class SupplierClaimController(SupplierClaimService service, ICurrentUserService currentUser) : ControllerBase
|
public class SupplierClaimController(SupplierClaimService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("api/supplier-claims/{id:guid}")]
|
[HttpGet("api/supplier-claims/{id:guid}")]
|
||||||
@@ -32,7 +30,6 @@ public class SupplierClaimController(SupplierClaimService service, ICurrentUserS
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("api/supplier-claims/{id:guid}/approve")]
|
[HttpPost("api/supplier-claims/{id:guid}/approve")]
|
||||||
[Authorize(Policy = "suppliers.claims.approve")]
|
|
||||||
public async Task<IActionResult> Approve(Guid id, ApproveSupplierClaimRequest request, CancellationToken ct)
|
public async Task<IActionResult> Approve(Guid id, ApproveSupplierClaimRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
@@ -46,7 +43,6 @@ public class SupplierClaimController(SupplierClaimService service, ICurrentUserS
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("api/supplier-claims/{id:guid}/reject")]
|
[HttpPost("api/supplier-claims/{id:guid}/reject")]
|
||||||
[Authorize(Policy = "suppliers.claims.approve")]
|
|
||||||
public async Task<IActionResult> Reject(Guid id, RejectSupplierClaimRequest request, CancellationToken ct)
|
public async Task<IActionResult> Reject(Guid id, RejectSupplierClaimRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Sync;
|
namespace Distribution.Features.Sync;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/sync")]
|
[Route("api/sync")]
|
||||||
[Authorize(Policy = "items.create")]
|
|
||||||
public class SyncController(SyncService service) : ControllerBase
|
public class SyncController(SyncService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpPost("uoms")]
|
[HttpPost("uoms")]
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Uom;
|
namespace Distribution.Features.Uom;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/uoms")]
|
[Route("api/uoms")]
|
||||||
[Authorize]
|
|
||||||
public class UomController(UomService service) : ControllerBase
|
public class UomController(UomService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "items.view")]
|
|
||||||
public async Task<IActionResult> GetAll() =>
|
public async Task<IActionResult> GetAll() =>
|
||||||
Ok(await service.GetAllAsync());
|
Ok(await service.GetAllAsync());
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "items.view")]
|
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -22,7 +18,6 @@ public class UomController(UomService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "items.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateUomRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateUomRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
var result = await service.CreateAsync(request, ct);
|
||||||
@@ -30,7 +25,6 @@ public class UomController(UomService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
[Authorize(Policy = "items.update")]
|
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateUomRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateUomRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateAsync(id, request, ct);
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
@@ -38,7 +32,6 @@ public class UomController(UomService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
[Authorize(Policy = "items.delete")]
|
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var deleted = await service.DeleteAsync(id, ct);
|
var deleted = await service.DeleteAsync(id, ct);
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.User;
|
namespace Distribution.Features.User;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/users")]
|
[Route("api/users")]
|
||||||
[Authorize]
|
|
||||||
public class UserController(UserService service) : ControllerBase
|
public class UserController(UserService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "users.view")]
|
|
||||||
public async Task<IActionResult> GetAll() =>
|
public async Task<IActionResult> GetAll() =>
|
||||||
Ok(await service.GetAllAsync());
|
Ok(await service.GetAllAsync());
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "users.view")]
|
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -22,7 +18,6 @@ public class UserController(UserService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "users.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateUserRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateUserRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
var result = await service.CreateAsync(request, ct);
|
||||||
@@ -30,7 +25,6 @@ public class UserController(UserService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
[Authorize(Policy = "users.update")]
|
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateUserRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateUserRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateAsync(id, request, ct);
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
@@ -38,7 +32,6 @@ public class UserController(UserService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}/role")]
|
[HttpPut("{id:guid}/role")]
|
||||||
[Authorize(Policy = "users.assign_role")]
|
|
||||||
public async Task<IActionResult> AssignRole(Guid id, AssignUserRoleRequest request, CancellationToken ct)
|
public async Task<IActionResult> AssignRole(Guid id, AssignUserRoleRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.AssignRoleAsync(id, request, ct);
|
var result = await service.AssignRoleAsync(id, request, ct);
|
||||||
@@ -46,7 +39,6 @@ public class UserController(UserService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}/scope")]
|
[HttpPut("{id:guid}/scope")]
|
||||||
[Authorize(Policy = "users.assign_scope")]
|
|
||||||
public async Task<IActionResult> AssignScope(Guid id, AssignUserScopeRequest request, CancellationToken ct)
|
public async Task<IActionResult> AssignScope(Guid id, AssignUserScopeRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var updated = await service.AssignScopeAsync(id, request, ct);
|
var updated = await service.AssignScopeAsync(id, request, ct);
|
||||||
@@ -54,7 +46,6 @@ public class UserController(UserService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
[Authorize(Policy = "users.deactivate")]
|
|
||||||
public async Task<IActionResult> Deactivate(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Deactivate(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var deactivated = await service.DeactivateAsync(id, ct);
|
var deactivated = await service.DeactivateAsync(id, ct);
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Vehicle;
|
namespace Distribution.Features.Vehicle;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/vehicles")]
|
[Route("api/vehicles")]
|
||||||
[Authorize]
|
|
||||||
public class VehicleController(VehicleService service) : ControllerBase
|
public class VehicleController(VehicleService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "vehicles.view")]
|
|
||||||
public async Task<IActionResult> GetAll() =>
|
public async Task<IActionResult> GetAll() =>
|
||||||
Ok(await service.GetAllAsync());
|
Ok(await service.GetAllAsync());
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "vehicles.view")]
|
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -22,7 +18,6 @@ public class VehicleController(VehicleService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "vehicles.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateVehicleRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateVehicleRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
var result = await service.CreateAsync(request, ct);
|
||||||
@@ -30,7 +25,6 @@ public class VehicleController(VehicleService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
[Authorize(Policy = "vehicles.update")]
|
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateVehicleRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateVehicleRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateAsync(id, request, ct);
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
@@ -38,7 +32,6 @@ public class VehicleController(VehicleService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
[Authorize(Policy = "vehicles.delete")]
|
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var deleted = await service.DeleteAsync(id, ct);
|
var deleted = await service.DeleteAsync(id, ct);
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.Warehouse;
|
namespace Distribution.Features.Warehouse;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/warehouses")]
|
[Route("api/warehouses")]
|
||||||
[Authorize]
|
|
||||||
public class WarehouseController(WarehouseService service) : ControllerBase
|
public class WarehouseController(WarehouseService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Policy = "warehouses.view")]
|
|
||||||
public async Task<IActionResult> GetAll() =>
|
public async Task<IActionResult> GetAll() =>
|
||||||
Ok(await service.GetAllAsync());
|
Ok(await service.GetAllAsync());
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
[Authorize(Policy = "warehouses.view")]
|
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
var result = await service.GetByIdAsync(id);
|
var result = await service.GetByIdAsync(id);
|
||||||
@@ -22,7 +18,6 @@ public class WarehouseController(WarehouseService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = "warehouses.create")]
|
|
||||||
public async Task<IActionResult> Create(CreateWarehouseRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateWarehouseRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
var result = await service.CreateAsync(request, ct);
|
||||||
@@ -30,7 +25,6 @@ public class WarehouseController(WarehouseService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
[Authorize(Policy = "warehouses.update")]
|
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateWarehouseRequest request, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, UpdateWarehouseRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.UpdateAsync(id, request, ct);
|
var result = await service.UpdateAsync(id, request, ct);
|
||||||
@@ -38,7 +32,6 @@ public class WarehouseController(WarehouseService service) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
[Authorize(Policy = "warehouses.delete")]
|
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var deleted = await service.DeleteAsync(id, ct);
|
var deleted = await service.DeleteAsync(id, ct);
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
|
||||||
|
namespace Distribution.Infra.Authorization;
|
||||||
|
|
||||||
|
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||||
|
public class AuthenticatedAttribute : Attribute, IAuthorizationFilter
|
||||||
|
{
|
||||||
|
public void OnAuthorization(AuthorizationFilterContext context)
|
||||||
|
{
|
||||||
|
if (context.HttpContext.User.Identity?.IsAuthenticated != true)
|
||||||
|
context.Result = new UnauthorizedResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Infra.Authorization;
|
namespace Distribution.Infra.Authorization;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/authorization")]
|
[Route("api/authorization")]
|
||||||
[Authorize]
|
|
||||||
public class AuthorizationController(
|
public class AuthorizationController(
|
||||||
IPermissionService permissionService,
|
IPermissionService permissionService,
|
||||||
IScopeAccessService scopeAccessService,
|
IScopeAccessService scopeAccessService,
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
|
//namespace Distribution.Infra.Authorization;
|
||||||
|
|
||||||
namespace Distribution.Infra.Authorization;
|
//public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
|
||||||
|
//{
|
||||||
|
// private readonly IPermissionService _permissionService;
|
||||||
|
|
||||||
public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
|
// public PermissionAuthorizationHandler(IPermissionService permissionService)
|
||||||
{
|
// {
|
||||||
private readonly IPermissionService _permissionService;
|
// _permissionService = permissionService;
|
||||||
|
// }
|
||||||
|
|
||||||
public PermissionAuthorizationHandler(IPermissionService permissionService)
|
// protected override async Task HandleRequirementAsync(
|
||||||
{
|
// AuthorizationHandlerContext context, PermissionRequirement requirement)
|
||||||
_permissionService = permissionService;
|
// {
|
||||||
}
|
// var roleIdClaim = context.User.FindFirst("role_id")?.Value;
|
||||||
|
// if (!Guid.TryParse(roleIdClaim, out var roleId))
|
||||||
|
// return;
|
||||||
|
|
||||||
protected override async Task HandleRequirementAsync(
|
// if (await _permissionService.HasPermissionAsync(roleId, requirement.PermissionCode))
|
||||||
AuthorizationHandlerContext context, PermissionRequirement requirement)
|
// context.Succeed(requirement);
|
||||||
{
|
// }
|
||||||
var roleIdClaim = context.User.FindFirst("role_id")?.Value;
|
//}
|
||||||
if (!Guid.TryParse(roleIdClaim, out var roleId))
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (await _permissionService.HasPermissionAsync(roleId, requirement.PermissionCode))
|
|
||||||
context.Succeed(requirement);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,32 +1,31 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
//using Microsoft.Extensions.Options;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
namespace Distribution.Infra.Authorization;
|
//namespace Distribution.Infra.Authorization;
|
||||||
|
|
||||||
public class PermissionPolicyProvider : IAuthorizationPolicyProvider
|
//public class PermissionPolicyProvider : IAuthorizationPolicyProvider
|
||||||
{
|
//{
|
||||||
private readonly DefaultAuthorizationPolicyProvider _fallback;
|
// private readonly DefaultAuthorizationPolicyProvider _fallback;
|
||||||
|
|
||||||
public PermissionPolicyProvider(IOptions<AuthorizationOptions> options)
|
// public PermissionPolicyProvider(IOptions<AuthorizationOptions> options)
|
||||||
{
|
// {
|
||||||
_fallback = new DefaultAuthorizationPolicyProvider(options);
|
// _fallback = new DefaultAuthorizationPolicyProvider(options);
|
||||||
}
|
// }
|
||||||
|
|
||||||
public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync();
|
// public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync();
|
||||||
|
|
||||||
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync();
|
// public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync();
|
||||||
|
|
||||||
public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
|
// public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
|
||||||
{
|
// {
|
||||||
if (policyName.Contains('.'))
|
// if (policyName.Contains('.'))
|
||||||
{
|
// {
|
||||||
var policy = new AuthorizationPolicyBuilder()
|
// var policy = new AuthorizationPolicyBuilder()
|
||||||
.RequireAuthenticatedUser()
|
// .RequireAuthenticatedUser()
|
||||||
.AddRequirements(new PermissionRequirement(policyName))
|
// .AddRequirements(new PermissionRequirement(policyName))
|
||||||
.Build();
|
// .Build();
|
||||||
return Task.FromResult<AuthorizationPolicy?>(policy);
|
// return Task.FromResult<AuthorizationPolicy?>(policy);
|
||||||
}
|
// }
|
||||||
|
|
||||||
return _fallback.GetPolicyAsync(policyName);
|
// return _fallback.GetPolicyAsync(policyName);
|
||||||
}
|
// }
|
||||||
}
|
//}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
|
//namespace Distribution.Infra.Authorization;
|
||||||
|
|
||||||
namespace Distribution.Infra.Authorization;
|
//public class PermissionRequirement : IAuthorizationRequirement
|
||||||
|
//{
|
||||||
|
// public string PermissionCode { get; }
|
||||||
|
|
||||||
public class PermissionRequirement : IAuthorizationRequirement
|
// public PermissionRequirement(string permissionCode)
|
||||||
{
|
// {
|
||||||
public string PermissionCode { get; }
|
// PermissionCode = permissionCode;
|
||||||
|
// }
|
||||||
public PermissionRequirement(string permissionCode)
|
//}
|
||||||
{
|
|
||||||
PermissionCode = permissionCode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
|
namespace Distribution.Middleware;
|
||||||
|
|
||||||
|
public class AuthMiddleware(RequestDelegate next, IConfiguration config)
|
||||||
|
{
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
var token = context.Request.Headers.Authorization
|
||||||
|
.ToString()
|
||||||
|
.Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase)
|
||||||
|
.Trim();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(token))
|
||||||
|
{
|
||||||
|
var key = config["Jwt:Key"];
|
||||||
|
if (!string.IsNullOrEmpty(key))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var principal = new JwtSecurityTokenHandler().ValidateToken(token,
|
||||||
|
new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)),
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidIssuer = config["Jwt:Issuer"],
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidAudience = config["Jwt:Audience"],
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ClockSkew = TimeSpan.FromMinutes(1)
|
||||||
|
}, out _);
|
||||||
|
|
||||||
|
context.User = principal;
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await next(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-64
@@ -1,6 +1,6 @@
|
|||||||
using System.Security.Claims;
|
|
||||||
using Distribution.Data;
|
using Distribution.Data;
|
||||||
using Distribution.Features.Auth;
|
using Distribution.Features.Auth;
|
||||||
|
using Distribution.Middleware;
|
||||||
using Distribution.Features.Brand;
|
using Distribution.Features.Brand;
|
||||||
using Distribution.Features.Category;
|
using Distribution.Features.Category;
|
||||||
using Distribution.Features.DeliveryRide;
|
using Distribution.Features.DeliveryRide;
|
||||||
@@ -34,10 +34,7 @@ using Distribution.Features.Stock;
|
|||||||
using Distribution.Features.Warehouse;
|
using Distribution.Features.Warehouse;
|
||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@@ -126,67 +123,26 @@ builder.Services.AddScoped<IPermissionRepository, PermissionRepository>();
|
|||||||
builder.Services.AddScoped<IScopeRepository, ScopeRepository>();
|
builder.Services.AddScoped<IScopeRepository, ScopeRepository>();
|
||||||
builder.Services.AddScoped<IPermissionService, PermissionService>();
|
builder.Services.AddScoped<IPermissionService, PermissionService>();
|
||||||
builder.Services.AddScoped<IScopeAccessService, ScopeAccessService>();
|
builder.Services.AddScoped<IScopeAccessService, ScopeAccessService>();
|
||||||
builder.Services.AddScoped<IAuthorizationHandler, PermissionAuthorizationHandler>();
|
// ── Auth Service connector ───────────────────────────────────────────────────
|
||||||
builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>();
|
builder.Services.Configure<AuthServiceOptions>(
|
||||||
|
builder.Configuration.GetSection(AuthServiceOptions.SectionName));
|
||||||
|
|
||||||
var jwtSection = builder.Configuration.GetSection("Jwt");
|
var authServiceBaseUrl = builder.Configuration[$"{AuthServiceOptions.SectionName}:BaseUrl"];
|
||||||
var jwtPublicKeyXml = jwtSection["PublicKey"] ?? throw new InvalidOperationException("Jwt:PublicKey is not configured.");
|
|
||||||
var jwtSigningKey = new RsaSecurityKey(RsaXmlKeyReader.ReadPublicKey(jwtPublicKeyXml));
|
|
||||||
|
|
||||||
builder.Services.AddAuthentication(options =>
|
builder.Services.AddHttpClient<AuthServiceClient>(client =>
|
||||||
{
|
{
|
||||||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
if (!string.IsNullOrWhiteSpace(authServiceBaseUrl))
|
||||||
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
client.BaseAddress = new Uri(authServiceBaseUrl);
|
||||||
})
|
});
|
||||||
.AddJwtBearer(options =>
|
|
||||||
{
|
|
||||||
options.TokenValidationParameters = new TokenValidationParameters
|
|
||||||
{
|
|
||||||
ValidateIssuer = true,
|
|
||||||
ValidIssuer = jwtSection["Issuer"],
|
|
||||||
ValidateAudience = true,
|
|
||||||
ValidAudience = jwtSection["Audience"],
|
|
||||||
ValidateIssuerSigningKey = true,
|
|
||||||
IssuerSigningKey = jwtSigningKey,
|
|
||||||
ValidateLifetime = true,
|
|
||||||
ClockSkew = TimeSpan.FromMinutes(1)
|
|
||||||
};
|
|
||||||
|
|
||||||
// AuthHex issues the token and owns its claim shape; this app's authorization (permission
|
builder.Services.AddScoped<IAuthServiceClient, AuthServiceClient>();
|
||||||
// policies, role-level checks) runs against the *local* Role/Permission tables, so every
|
|
||||||
// validated token is mapped back to a local User/Role once here, not duplicated per
|
|
||||||
// endpoint. A token for a user with no matching local User record is rejected outright —
|
|
||||||
// login via AuthHex does not implicitly provision a local user.
|
|
||||||
options.Events = new JwtBearerEvents
|
|
||||||
{
|
|
||||||
OnTokenValidated = async context =>
|
|
||||||
{
|
|
||||||
var subClaim = context.Principal?.FindFirst("sub")?.Value
|
|
||||||
?? context.Principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
|
||||||
|
|
||||||
if (!Guid.TryParse(subClaim, out var userId))
|
var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [];
|
||||||
{
|
builder.Services.AddCors(options =>
|
||||||
context.Fail("Token is missing a valid user id claim.");
|
options.AddDefaultPolicy(policy =>
|
||||||
return;
|
policy.WithOrigins(allowedOrigins)
|
||||||
}
|
.AllowAnyHeader()
|
||||||
|
.AllowAnyMethod()));
|
||||||
var userRepo = context.HttpContext.RequestServices.GetRequiredService<IUserRepository>();
|
|
||||||
var user = await userRepo.GetByIdAsync(userId);
|
|
||||||
if (user is null || !user.IsActive)
|
|
||||||
{
|
|
||||||
context.Fail("User not found or inactive.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var identity = (ClaimsIdentity)context.Principal!.Identity!;
|
|
||||||
identity.AddClaim(new Claim("username", user.Username));
|
|
||||||
identity.AddClaim(new Claim("role_id", user.FK_RoleId.ToString()));
|
|
||||||
identity.AddClaim(new Claim("role_level", (user.Role?.Level ?? 0).ToString()));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.Services.AddAuthorization();
|
|
||||||
|
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
@@ -202,9 +158,9 @@ if (app.Environment.IsDevelopment())
|
|||||||
app.UseSwaggerUI();
|
app.UseSwaggerUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
app.UseHttpsRedirection();
|
//app.UseHttpsRedirection();
|
||||||
app.UseAuthentication();
|
app.UseCors();
|
||||||
app.UseAuthorization();
|
app.UseMiddleware<AuthMiddleware>();
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"Urls": "http://localhost:5089",
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": "Server=187.127.102.190;Port=3306;Database=distribution;User=kalana;Password=kalana@hexdive"
|
"DefaultConnection": "Server=187.127.102.190;Port=3306;Database=distribution;User=kalana;Password=kalana@hexdive"
|
||||||
},
|
},
|
||||||
@@ -9,6 +10,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
|
"Cors": {
|
||||||
|
"AllowedOrigins": [ "http://localhost:3000" ]
|
||||||
|
},
|
||||||
"Jwt": {
|
"Jwt": {
|
||||||
"PublicKey": "<RSAKeyValue><Modulus>1LlNkMBQNdpXJiDal7XMxkG/3ad+YBsMCuY9JD/abHMzniFXtQlovjfbeaaHJ0v1kvSo9731CJ0YC1qhPU5rPQwZwxOWZ9BOBZlMDghONdjOH/HyCUbb5Z18ibqc0QenFSnEYz+jkVZiayj8DV/+VUe+eKzpQTlU6aWtHvlbwfuXaDu+QvFlpLJ7/m8na+0s2nYhLX8Wfi4C/2AoNaYhFkIwYhMMGoaSHuIoQ5R6181Rh0gKvYopRW+IpTD5RV8bXV3AM6zOcoisOifBYROHwA5ZZpoHXuTvHYmPWW8kL8PKme7BwBldPi8KrJUroRE+WXA87aAA5Wtt1oxePcXvhQ==</Modulus><Exponent>AQAB</Exponent></RSAKeyValue>",
|
"PublicKey": "<RSAKeyValue><Modulus>1LlNkMBQNdpXJiDal7XMxkG/3ad+YBsMCuY9JD/abHMzniFXtQlovjfbeaaHJ0v1kvSo9731CJ0YC1qhPU5rPQwZwxOWZ9BOBZlMDghONdjOH/HyCUbb5Z18ibqc0QenFSnEYz+jkVZiayj8DV/+VUe+eKzpQTlU6aWtHvlbwfuXaDu+QvFlpLJ7/m8na+0s2nYhLX8Wfi4C/2AoNaYhFkIwYhMMGoaSHuIoQ5R6181Rh0gKvYopRW+IpTD5RV8bXV3AM6zOcoisOifBYROHwA5ZZpoHXuTvHYmPWW8kL8PKme7BwBldPi8KrJUroRE+WXA87aAA5Wtt1oxePcXvhQ==</Modulus><Exponent>AQAB</Exponent></RSAKeyValue>",
|
||||||
"Audience": "AuthHexClient",
|
"Audience": "AuthHexClient",
|
||||||
@@ -21,5 +25,8 @@
|
|||||||
},
|
},
|
||||||
"StoreManagement": {
|
"StoreManagement": {
|
||||||
"UnverifiedKycOrderThreshold": 0
|
"UnverifiedKycOrderThreshold": 0
|
||||||
|
},
|
||||||
|
"AuthService": {
|
||||||
|
"BaseUrl": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,16 +64,29 @@ When `FulfillImmediately = false`, no stock movement — order stays Pending for
|
|||||||
|
|
||||||
**Feature folder:** `Features/Payment/`
|
**Feature folder:** `Features/Payment/`
|
||||||
|
|
||||||
### API Endpoints
|
### Sales API Endpoints
|
||||||
|
|
||||||
| Method | Route | Who | Description |
|
| Method | Route | Who | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| POST | `/api/sales/{id}/payments` | Driver/Agent | Record a payment against a sale |
|
| GET | `/api/sales` | Any | List sales — supports `?rideId=`, `?page=`, `?pageSize=` query params |
|
||||||
| GET | `/api/sales/{id}/payments` | Any | List all payments for a sale |
|
| POST | `/api/sales/{saleId:guid}/payments` | Driver/Agent | Record a payment against a sale |
|
||||||
|
| GET | `/api/sales/{saleId:guid}/payments` | Any | List all payments for a sale |
|
||||||
|
|
||||||
|
**`GET /api/sales` filter params:**
|
||||||
|
|
||||||
|
| Param | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `rideId` | `Guid?` | Filter to sales created on a specific ride |
|
||||||
|
| `page` | `int` | Page number (default 1) |
|
||||||
|
| `pageSize` | `int` | Items per page (default 20) |
|
||||||
|
|
||||||
|
Returns `PagedResponse<SaleResponse>`: `{ page, pageSize, total, items }`.
|
||||||
|
|
||||||
|
Scope rules still apply: non-global users only see sales for stores in their scope.
|
||||||
|
|
||||||
### Payment Creation Logic
|
### Payment Creation Logic
|
||||||
|
|
||||||
On `POST /api/sales/{id}/payments`:
|
On `POST /api/sales/{saleId:guid}/payments`:
|
||||||
1. Validate sale exists and is not `Cancelled`.
|
1. Validate sale exists and is not `Cancelled`.
|
||||||
2. Add `Payment` record (`FK_SaleId`, `FK_StoreId` from sale, `Amount`, `Method`, `CollectedAt`, `CollectedBy`).
|
2. Add `Payment` record (`FK_SaleId`, `FK_StoreId` from sale, `Amount`, `Method`, `CollectedAt`, `CollectedBy`).
|
||||||
3. Sum all payments for this sale. Recalculate `Sale.PaymentStatus`:
|
3. Sum all payments for this sale. Recalculate `Sale.PaymentStatus`:
|
||||||
|
|||||||
@@ -100,8 +100,8 @@ One migration needed:
|
|||||||
### New
|
### New
|
||||||
| Method | Route | Permission | Notes |
|
| Method | Route | Permission | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| POST | /api/stores/{id}/kyc-documents | stores.update | Multipart upload, appends a KYC document |
|
| POST | /api/stores/{storeId:guid}/kyc-documents | stores.update | Multipart upload, appends a KYC document |
|
||||||
| GET | /api/stores/{id}/kyc-documents | stores.view | Lists documents for a store |
|
| GET | /api/stores/{storeId:guid}/kyc-documents | stores.view | Lists documents for a store |
|
||||||
| PUT | /api/stores/{id}/kyc/verify | stores.kyc.verify | Sets KycStatus, body carries `status` (Verified/Rejected) + optional `notes` |
|
| PUT | /api/stores/{id}/kyc/verify | stores.kyc.verify | Sets KycStatus, body carries `status` (Verified/Rejected) + optional `notes` |
|
||||||
| PUT | /api/stores/{id}/credit-limit | stores.credit.update | Requires Supervisor (roleLevel ≥ 20) or above |
|
| PUT | /api/stores/{id}/credit-limit | stores.credit.update | Requires Supervisor (roleLevel ≥ 20) or above |
|
||||||
| PUT | /api/stores/{id}/outstanding-balance | stores.credit.update | Manual adjustment until Finance owns this; same permission as credit-limit |
|
| PUT | /api/stores/{id}/outstanding-balance | stores.credit.update | Manual adjustment until Finance owns this; same permission as credit-limit |
|
||||||
@@ -115,7 +115,7 @@ One migration needed:
|
|||||||
|
|
||||||
## Request / Response Shapes
|
## Request / Response Shapes
|
||||||
|
|
||||||
### POST `/api/stores/{id}/kyc-documents` (multipart/form-data)
|
### POST `/api/stores/{storeId:guid}/kyc-documents` (multipart/form-data)
|
||||||
Fields: `documentType` (string), `file` (binary).
|
Fields: `documentType` (string), `file` (binary).
|
||||||
|
|
||||||
Response:
|
Response:
|
||||||
@@ -247,7 +247,7 @@ ever belong to a Store and have no independent lifecycle.
|
|||||||
|
|
||||||
### KYC upload logic (pseudo)
|
### KYC upload logic (pseudo)
|
||||||
```
|
```
|
||||||
POST /api/stores/{id}/kyc-documents — multipart (documentType, file):
|
POST /api/stores/{storeId:guid}/kyc-documents — multipart (documentType, file):
|
||||||
1. Load Store by id, reject if missing/inactive or caller lacks store scope
|
1. Load Store by id, reject if missing/inactive or caller lacks store scope
|
||||||
2. Ensure wwwroot/uploads/kyc/{storeId}/ exists
|
2. Ensure wwwroot/uploads/kyc/{storeId}/ exists
|
||||||
3. Save file as {guid}.{ext}, record FileReference = "uploads/kyc/{storeId}/{guid}.{ext}"
|
3. Save file as {guid}.{ext}, record FileReference = "uploads/kyc/{storeId}/{guid}.{ext}"
|
||||||
|
|||||||
@@ -347,7 +347,7 @@ GET /api/schemes/eligible?storeId=...&orderId=...
|
|||||||
|
|
||||||
**Apply it:**
|
**Apply it:**
|
||||||
```
|
```
|
||||||
POST /api/orders/{orderId}/apply-scheme
|
POST /api/orders/{id}/apply-scheme
|
||||||
{ "schemeId": "..." }
|
{ "schemeId": "..." }
|
||||||
```
|
```
|
||||||
Adds an `OrderItem` line (`Soap-500ml`, qty 2, `UnitPrice = 0`) and a `SchemeRedemption` with
|
Adds an `OrderItem` line (`Soap-500ml`, qty 2, `UnitPrice = 0`) and a `SchemeRedemption` with
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ home until this step. There is no invoice, payment, or GL entity for warehouse-f
|
|||||||
| Function reference | Endpoint intent |
|
| Function reference | Endpoint intent |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `createInvoice(orderId)` | `POST /api/orders/{id}/invoice` — the warehouse-fulfilled counterpart to [06_sale_ride.md](./06_sale_ride.md)'s field invoice creation |
|
| `createInvoice(orderId)` | `POST /api/orders/{id}/invoice` — the warehouse-fulfilled counterpart to [06_sale_ride.md](./06_sale_ride.md)'s field invoice creation |
|
||||||
| `recordPayment(invoiceId, amount, method)` | `POST /api/invoices/{id}/payments` |
|
| `recordPayment(invoiceId, amount, method)` | Current implemented path is `POST /api/sales/{saleId:guid}/payments` for field-sale collections; `POST /api/invoices/{id}/payments` remains the target endpoint for this future invoice-based finance step |
|
||||||
| `postToGeneralLedger(transactionId)` | Internal — triggered on invoice/payment/credit-note events |
|
| `postToGeneralLedger(transactionId)` | Internal — triggered on invoice/payment/credit-note events |
|
||||||
| `generateTaxInvoice(invoiceId, jurisdiction)` | `GET /api/invoices/{id}/tax-invoice?jurisdiction=...` |
|
| `generateTaxInvoice(invoiceId, jurisdiction)` | `GET /api/invoices/{id}/tax-invoice?jurisdiction=...` |
|
||||||
| `getFinancialReport(entityId, period, reportType)` | `GET /api/finance/reports?entityId=...&period=...&reportType=...` |
|
| `getFinancialReport(entityId, period, reportType)` | `GET /api/finance/reports?entityId=...&period=...&reportType=...` |
|
||||||
|
|||||||
@@ -143,9 +143,9 @@ here — see `SRS_User_Management_Module.pdf`.
|
|||||||
|
|
||||||
| Function | What it does | Status |
|
| Function | What it does | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `createStore(storeData, kycDocs)` | Onboards a new outlet with required KYC. | Partially implemented - store create exists, and KYC documents can now be uploaded/listed via separate `/api/stores/{id}/kyc-documents` endpoints rather than inline on create. |
|
| `createStore(storeData, kycDocs)` | Onboards a new outlet with required KYC. | Partially implemented - store create exists, and KYC documents can now be uploaded/listed via separate `/api/stores/{storeId:guid}/kyc-documents` endpoints rather than inline on create. |
|
||||||
| `uploadStoreKycDocument(storeId, documentType, file)` | Appends a KYC document to a store. | Implemented — `POST /api/stores/{id}/kyc-documents`. |
|
| `uploadStoreKycDocument(storeId, documentType, file)` | Appends a KYC document to a store. | Implemented — `POST /api/stores/{storeId:guid}/kyc-documents`. |
|
||||||
| `listStoreKycDocuments(storeId)` | Lists KYC documents for a store. | Implemented — `GET /api/stores/{id}/kyc-documents`. |
|
| `listStoreKycDocuments(storeId)` | Lists KYC documents for a store. | Implemented — `GET /api/stores/{storeId:guid}/kyc-documents`. |
|
||||||
| `verifyStoreKyc(storeId)` | Marks KYC as verified/rejected by an approver. | Implemented - `PUT /api/stores/{id}/kyc/verify` updates `Store.KycStatus`. |
|
| `verifyStoreKyc(storeId)` | Marks KYC as verified/rejected by an approver. | Implemented - `PUT /api/stores/{id}/kyc/verify` updates `Store.KycStatus`. |
|
||||||
| `updateStoreCreditLimit(storeId, newLimit, approverId)` | Changes a store's credit limit, subject to approval authority. | Implemented - `PUT /api/stores/{id}/credit-limit` updates `Store.CreditLimit` and requires the `stores.credit.update` permission plus role level >= 20. |
|
| `updateStoreCreditLimit(storeId, newLimit, approverId)` | Changes a store's credit limit, subject to approval authority. | Implemented - `PUT /api/stores/{id}/credit-limit` updates `Store.CreditLimit` and requires the `stores.credit.update` permission plus role level >= 20. |
|
||||||
| `updateStoreOutstandingBalance(storeId, amount, reason?)` | Manually adjusts the cached outstanding balance. | Implemented — `PUT /api/stores/{id}/outstanding-balance`. |
|
| `updateStoreOutstandingBalance(storeId, amount, reason?)` | Manually adjusts the cached outstanding balance. | Implemented — `PUT /api/stores/{id}/outstanding-balance`. |
|
||||||
@@ -281,7 +281,7 @@ here — see `SRS_User_Management_Module.pdf`.
|
|||||||
| Function | What it does | Status |
|
| Function | What it does | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `createInvoice(orderId)` | Generates a formal invoice from a fulfilled order. | Not implemented — no `Invoice` entity exists; `Sale`/`Order` remain the billable record. |
|
| `createInvoice(orderId)` | Generates a formal invoice from a fulfilled order. | Not implemented — no `Invoice` entity exists; `Sale`/`Order` remain the billable record. |
|
||||||
| `recordPayment(invoiceId, amount, method)` | Logs a payment/collection against an invoice. | Implemented against `Sale` rather than an invoice — `POST /api/sales/{saleId}/payments` (`Features/Payment`). Each payment sums against `Sale.TotalAmount` and updates `Sale.PaymentStatus` (`Pending`/`PartiallyPaid`/`Paid`); rejects (400) if the sale is `Cancelled`. `Method` is `Cash` or `Credit` (`Models/Enums.cs`). |
|
| `recordPayment(invoiceId, amount, method)` | Logs a payment/collection against an invoice. | Implemented against `Sale` rather than an invoice — `POST /api/sales/{saleId:guid}/payments` (`Features/Payment`). Each payment sums against `Sale.TotalAmount` and updates `Sale.PaymentStatus` (`Pending`/`PartiallyPaid`/`Paid`); rejects (400) if the sale is `Cancelled`. `Method` is `Cash` or `Credit` (`Models/Enums.cs`). |
|
||||||
| `postToGeneralLedger(transactionId)` | Posts a financial transaction to the GL. | Not implemented — no GL/ledger entity exists. |
|
| `postToGeneralLedger(transactionId)` | Posts a financial transaction to the GL. | Not implemented — no GL/ledger entity exists. |
|
||||||
| `generateTaxInvoice(invoiceId, jurisdiction)` | Produces a tax-compliant invoice (GST/VAT e-invoicing). | Not implemented. |
|
| `generateTaxInvoice(invoiceId, jurisdiction)` | Produces a tax-compliant invoice (GST/VAT e-invoicing). | Not implemented. |
|
||||||
| `getFinancialReport(entityId, period, reportType)` | Returns P&L/cash-flow reports scoped to a region/area. | Not implemented. |
|
| `getFinancialReport(entityId, period, reportType)` | Returns P&L/cash-flow reports scoped to a region/area. | Not implemented. |
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
---
|
||||||
|
name: shadcn
|
||||||
|
description: Manages shadcn components and projects — adding, searching, fixing, debugging, styling, and composing UI, including chat interfaces. Provides project context, component docs, and usage examples. Applies when working with shadcn/ui, component registries, presets, --preset codes, or any project with a components.json file. Also triggers for "shadcn init", "create an app with --preset", or "switch to --preset".
|
||||||
|
user-invocable: false
|
||||||
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
|
---
|
||||||
|
|
||||||
|
# shadcn/ui
|
||||||
|
|
||||||
|
A framework for building ui, components and design systems. Components are added as source code to the user's project via the CLI.
|
||||||
|
|
||||||
|
> **IMPORTANT:** Run all CLI commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest` — based on the project's `packageManager`. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
|
||||||
|
|
||||||
|
## Current Project Context
|
||||||
|
|
||||||
|
```json
|
||||||
|
!`npx shadcn@latest info --json`
|
||||||
|
```
|
||||||
|
|
||||||
|
The JSON above contains the project config and installed components. Use `npx shadcn@latest docs <component>` to get documentation and example URLs for any component.
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
|
||||||
|
1. **Use existing components first.** Use `npx shadcn@latest search` to check registries before writing custom UI. Check community registries too.
|
||||||
|
2. **Compose, don't reinvent.** Settings page = Tabs + Card + form controls. Dashboard = Sidebar + Card + Chart + Table.
|
||||||
|
3. **Use built-in variants before custom styles.** `variant="outline"`, `size="sm"`, etc.
|
||||||
|
4. **Use semantic colors.** `bg-primary`, `text-muted-foreground` — never raw values like `bg-blue-500`.
|
||||||
|
|
||||||
|
## Critical Rules
|
||||||
|
|
||||||
|
These rules are **always enforced**. Each links to a file with Incorrect/Correct code pairs.
|
||||||
|
|
||||||
|
### Styling & Tailwind → [styling.md](./rules/styling.md)
|
||||||
|
|
||||||
|
- **`className` for layout, not styling.** Never override component colors or typography.
|
||||||
|
- **No `space-x-*` or `space-y-*`.** Use `flex` with `gap-*`. For vertical stacks, `flex flex-col gap-*`.
|
||||||
|
- **Use `size-*` when width and height are equal.** `size-10` not `w-10 h-10`.
|
||||||
|
- **Use `truncate` shorthand.** Not `overflow-hidden text-ellipsis whitespace-nowrap`.
|
||||||
|
- **No manual `dark:` color overrides.** Use semantic tokens (`bg-background`, `text-muted-foreground`).
|
||||||
|
- **Use `cn()` for conditional classes.** Don't write manual template literal ternaries.
|
||||||
|
- **No manual `z-index` on overlay components.** Dialog, Sheet, Popover, etc. handle their own stacking.
|
||||||
|
|
||||||
|
### Forms & Inputs → [forms.md](./rules/forms.md)
|
||||||
|
|
||||||
|
- **Forms use `FieldGroup` + `Field`.** Never use raw `div` with `space-y-*` or `grid gap-*` for form layout.
|
||||||
|
- **`InputGroup` uses `InputGroupInput`/`InputGroupTextarea`.** Never raw `Input`/`Textarea` inside `InputGroup`.
|
||||||
|
- **Buttons inside inputs use `InputGroup` + `InputGroupAddon`.**
|
||||||
|
- **Option sets (2–7 choices) use `ToggleGroup`.** Don't loop `Button` with manual active state.
|
||||||
|
- **`FieldSet` + `FieldLegend` for grouping related checkboxes/radios.** Don't use a `div` with a heading.
|
||||||
|
- **Field validation uses `data-invalid` + `aria-invalid`.** `data-invalid` on `Field`, `aria-invalid` on the control. For disabled: `data-disabled` on `Field`, `disabled` on the control.
|
||||||
|
|
||||||
|
### Component Structure → [composition.md](./rules/composition.md)
|
||||||
|
|
||||||
|
- **Items always inside their Group.** `SelectItem` → `SelectGroup`. `DropdownMenuItem` → `DropdownMenuGroup`. `CommandItem` → `CommandGroup`.
|
||||||
|
- **Use `asChild` (radix) or `render` (base) for custom triggers.** Check `base` field from `npx shadcn@latest info`. → [base-vs-radix.md](./rules/base-vs-radix.md)
|
||||||
|
- **Dialog, Sheet, and Drawer always need a Title.** `DialogTitle`, `SheetTitle`, `DrawerTitle` required for accessibility. Use `className="sr-only"` if visually hidden.
|
||||||
|
- **Use full Card composition.** `CardHeader`/`CardTitle`/`CardDescription`/`CardContent`/`CardFooter`. Don't dump everything in `CardContent`.
|
||||||
|
- **Button has no `isPending`/`isLoading`.** Compose with `Spinner` + `data-icon` + `disabled`.
|
||||||
|
- **`TabsTrigger` must be inside `TabsList`.** Never render triggers directly in `Tabs`.
|
||||||
|
- **`Avatar` always needs `AvatarFallback`.** For when the image fails to load.
|
||||||
|
|
||||||
|
### Use Components, Not Custom Markup → [composition.md](./rules/composition.md)
|
||||||
|
|
||||||
|
- **Use existing components before custom markup.** Check if a component exists before writing a styled `div`.
|
||||||
|
- **Callouts use `Alert`.** Don't build custom styled divs.
|
||||||
|
- **Empty states use `Empty`.** Don't build custom empty state markup.
|
||||||
|
- **Toast follows the project base.** Use `toast` from the `toast` component for
|
||||||
|
Base UI projects. Use `toast()` from `sonner` for Radix and React Aria
|
||||||
|
projects.
|
||||||
|
- **Use `Separator`** instead of `<hr>` or `<div className="border-t">`.
|
||||||
|
- **Use `Skeleton`** for loading placeholders. No custom `animate-pulse` divs.
|
||||||
|
- **Use `Badge`** instead of custom styled spans.
|
||||||
|
|
||||||
|
### Icons → [icons.md](./rules/icons.md)
|
||||||
|
|
||||||
|
- **Icons in `Button` use `data-icon`.** `data-icon="inline-start"` or `data-icon="inline-end"` on the icon.
|
||||||
|
- **No sizing classes on icons inside components.** Components handle icon sizing via CSS. No `size-4` or `w-4 h-4`.
|
||||||
|
- **Pass icons as objects, not string keys.** `icon={CheckIcon}`, not a string lookup.
|
||||||
|
|
||||||
|
### Chat & Messaging → [chat.md](./rules/chat.md)
|
||||||
|
|
||||||
|
- **Chat UI composes the chat primitives.** Conversations use `MessageScroller`, rows use `Message`, surfaces use `Bubble`. Never hand-rolled bubble `div`s or a raw scroll container.
|
||||||
|
- **`MessageScroller` owns scroll behavior.** Streaming follow, anchoring, and jump-to-latest (`MessageScrollerButton`) are built in. Don't write a `useStickToBottom`/`ResizeObserver` hook.
|
||||||
|
- **Attachments use `Attachment`; system notes and dividers use `Marker`.** Not `Item` cards or `Separator` + a label.
|
||||||
|
|
||||||
|
### CLI
|
||||||
|
|
||||||
|
- **Never decode preset codes or build preset URLs manually.** Use `npx shadcn@latest preset decode <code>`, `preset url <code>`, or `preset open <code>`. For project-aware preset detection, use `npx shadcn@latest preset resolve`.
|
||||||
|
- **Apply preset codes directly with the CLI.** Use `npx shadcn@latest apply <code>` for existing projects, or `npx shadcn@latest init --preset <code>` when initializing.
|
||||||
|
|
||||||
|
## Key Patterns
|
||||||
|
|
||||||
|
These are the most common patterns that differentiate correct shadcn/ui code. For edge cases, see the linked rule files above.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Form layout: FieldGroup + Field, not div + Label.
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||||
|
<Input id="email" />
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
// Validation: data-invalid on Field, aria-invalid on the control.
|
||||||
|
<Field data-invalid>
|
||||||
|
<FieldLabel>Email</FieldLabel>
|
||||||
|
<Input aria-invalid />
|
||||||
|
<FieldDescription>Invalid email.</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
// Icons in buttons: data-icon, no sizing classes.
|
||||||
|
<Button>
|
||||||
|
<SearchIcon data-icon="inline-start" />
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
// Spacing: gap-*, not space-y-*.
|
||||||
|
<div className="flex flex-col gap-4"> // correct
|
||||||
|
<div className="space-y-4"> // wrong
|
||||||
|
|
||||||
|
// Equal dimensions: size-*, not w-* h-*.
|
||||||
|
<Avatar className="size-10"> // correct
|
||||||
|
<Avatar className="w-10 h-10"> // wrong
|
||||||
|
|
||||||
|
// Status colors: Badge variants or semantic tokens, not raw colors.
|
||||||
|
<Badge variant="secondary">+20.1%</Badge> // correct
|
||||||
|
<span className="text-emerald-600">+20.1%</span> // wrong
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Selection
|
||||||
|
|
||||||
|
| Need | Use |
|
||||||
|
| -------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||||
|
| Button/action | `Button` with appropriate variant |
|
||||||
|
| Form inputs | `Input`, `Select`, `Combobox`, `Switch`, `Checkbox`, `RadioGroup`, `Textarea`, `InputOTP`, `Slider` |
|
||||||
|
| Toggle between 2–5 options | `ToggleGroup` + `ToggleGroupItem` |
|
||||||
|
| Data display | `Table`, `Card`, `Badge`, `Avatar` |
|
||||||
|
| Navigation | `Sidebar`, `NavigationMenu`, `Breadcrumb`, `Tabs`, `Pagination` |
|
||||||
|
| Overlays | `Dialog` (modal), `Sheet` (side panel), `Drawer` (bottom sheet), `AlertDialog` (confirmation) |
|
||||||
|
| Feedback | `toast` (Base UI), `sonner` (Radix/Aria), `Alert`, `Progress`, `Skeleton`, `Spinner` |
|
||||||
|
| Command palette | `Command` inside `Dialog` |
|
||||||
|
| Charts | `Chart` (wraps Recharts) |
|
||||||
|
| Layout | `Card`, `Separator`, `Resizable`, `ScrollArea`, `Accordion`, `Collapsible` |
|
||||||
|
| Empty states | `Empty` |
|
||||||
|
| Menus | `DropdownMenu`, `ContextMenu`, `Menubar` |
|
||||||
|
| Tooltips/info | `Tooltip`, `HoverCard`, `Popover` |
|
||||||
|
| Chat / conversation UI | `MessageScroller`, `Message`, `Bubble`, `Attachment`, `Marker` |
|
||||||
|
|
||||||
|
## Key Fields
|
||||||
|
|
||||||
|
The injected project context contains these key fields:
|
||||||
|
|
||||||
|
- **`aliases`** → use the actual alias prefix for imports (e.g. `@/`, `~/`), never hardcode.
|
||||||
|
- **`isRSC`** → when `true`, components using `useState`, `useEffect`, event handlers, or browser APIs need `"use client"` at the top of the file. Always reference this field when advising on the directive.
|
||||||
|
- **`tailwindVersion`** → `"v4"` uses `@theme inline` blocks; `"v3"` uses `tailwind.config.js`.
|
||||||
|
- **`tailwindCssFile`** → the global CSS file where custom CSS variables are defined. Always edit this file, never create a new one.
|
||||||
|
- **`style`** → component visual treatment (e.g. `nova`, `vega`).
|
||||||
|
- **`base`** → primitive library (`radix` or `base`). Affects component APIs and available props.
|
||||||
|
- **`iconLibrary`** → determines icon imports. Use `lucide-react` for `lucide`, `@tabler/icons-react` for `tabler`, etc. Never assume `lucide-react`.
|
||||||
|
- **`resolvedPaths`** → exact file-system destinations for components, utils, hooks, etc.
|
||||||
|
- **`framework`** → routing and file conventions (e.g. Next.js App Router vs Vite SPA).
|
||||||
|
- **`packageManager`** → use this for any non-shadcn dependency installs (e.g. `pnpm add date-fns` vs `npm install date-fns`).
|
||||||
|
- **`preset`** → resolved preset code and values for the current project. Use `npx shadcn@latest preset resolve --json` when you only need preset information.
|
||||||
|
|
||||||
|
See [cli.md — `info` command](./cli.md) for the full field reference.
|
||||||
|
|
||||||
|
## Component Docs, Examples, and Usage
|
||||||
|
|
||||||
|
Run `npx shadcn@latest docs <component>` to get the URLs for a component's documentation, examples, and API reference. Fetch these URLs to get the actual content.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest docs button dialog select
|
||||||
|
```
|
||||||
|
|
||||||
|
**When creating, fixing, debugging, or using a component, always run `npx shadcn@latest docs` and fetch the URLs first.** This ensures you're working with the correct API and usage patterns rather than guessing.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. **Get project context** — already injected above. Run `npx shadcn@latest info` again if you need to refresh.
|
||||||
|
2. **Check installed components first** — before running `add`, always check the `components` list from project context or list the `resolvedPaths.ui` directory. Don't import components that haven't been added, and don't re-add ones already installed.
|
||||||
|
3. **Find components** — `npx shadcn@latest search`.
|
||||||
|
4. **Get docs and examples** — run `npx shadcn@latest docs <component>` to get URLs, then fetch them. Use `npx shadcn@latest view` to browse registry items you haven't installed. To preview changes to installed components, use `npx shadcn@latest add --diff`.
|
||||||
|
5. **Install or update** — `npx shadcn@latest add`. When updating existing components, use `--dry-run` and `--diff` to preview changes first (see [Updating Components](#updating-components) below).
|
||||||
|
6. **Fix imports in third-party components** — After adding components from community registries (e.g. `@bundui`, `@magicui`), check the added non-UI files for hardcoded import paths like `@/components/ui/...`. These won't match the project's actual aliases. Use `npx shadcn@latest info` to get the correct `ui` alias (e.g. `@workspace/ui/components`) and rewrite the imports accordingly. The CLI rewrites imports for its own UI files, but third-party registry components may use default paths that don't match the project.
|
||||||
|
7. **Review added components** — After adding a component or block from any registry, **always read the added files and verify they are correct**. Check for missing sub-components (e.g. `SelectItem` without `SelectGroup`), missing imports, incorrect composition, or violations of the [Critical Rules](#critical-rules). Also replace any icon imports with the project's `iconLibrary` from the project context (e.g. if the registry item uses `lucide-react` but the project uses `hugeicons`, swap the imports and icon names accordingly). Fix all issues before moving on.
|
||||||
|
8. **Registry must be explicit** — When the user asks to add a block or component, **do not guess the registry**. If no registry is specified (e.g. user says "add a login block" without specifying `@shadcn`, `@tailark`, `owner/repo`, etc.), ask which registry to use. Never default to a registry on behalf of the user.
|
||||||
|
9. **Switching presets** — Ask the user first: **overwrite**, **partial**, **merge**, or **skip**?
|
||||||
|
- **Inspect current preset**: `npx shadcn@latest preset resolve`. Use `--json` when you need structured values.
|
||||||
|
- **Inspect incoming preset**: `npx shadcn@latest preset decode <code>`. Use `preset url <code>` or `preset open <code>` to share or open the preset builder.
|
||||||
|
- **Overwrite**: `npx shadcn@latest apply <code>`. Overwrites detected components, fonts, and CSS variables.
|
||||||
|
- **Partial**: `npx shadcn@latest apply <code> --only theme,font`. Updates only the selected preset parts without reinstalling UI components. Supported values are `theme` and `font`; comma-separated combinations are allowed. `icon` is intentionally not supported, because icon changes may require full component reinstall and transforms.
|
||||||
|
- **Merge**: `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to list installed components, then for each installed component use `--dry-run` and `--diff` to [smart merge](#updating-components) it individually.
|
||||||
|
- **Skip**: `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS, leaves components as-is.
|
||||||
|
- **Important**: Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
|
||||||
|
|
||||||
|
## Updating Components
|
||||||
|
|
||||||
|
When the user asks to update a component from upstream while keeping their local changes, use `--dry-run` and `--diff` to intelligently merge. **NEVER fetch raw files from GitHub manually — always use the CLI.**
|
||||||
|
|
||||||
|
1. Run `npx shadcn@latest add <component> --dry-run` to see all files that would be affected.
|
||||||
|
2. For each file, run `npx shadcn@latest add <component> --diff <file>` to see what changed upstream vs local.
|
||||||
|
3. Decide per file based on the diff:
|
||||||
|
- No local changes → safe to overwrite.
|
||||||
|
- Has local changes → read the local file, analyze the diff, and apply upstream updates while preserving local modifications.
|
||||||
|
- User says "just update everything" → use `--overwrite`, but confirm first.
|
||||||
|
4. **Never use `--overwrite` without the user's explicit approval.**
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a new project.
|
||||||
|
npx shadcn@latest init --name my-app --preset base-nova
|
||||||
|
npx shadcn@latest init --name my-app --preset a2r6bw --template vite
|
||||||
|
|
||||||
|
# Create a monorepo project.
|
||||||
|
npx shadcn@latest init --name my-app --preset base-nova --monorepo
|
||||||
|
npx shadcn@latest init --name my-app --preset base-nova --template next --monorepo
|
||||||
|
|
||||||
|
# Initialize existing project.
|
||||||
|
npx shadcn@latest init --preset base-nova
|
||||||
|
npx shadcn@latest init --defaults # shortcut: --template=next --preset=nova (base style implied)
|
||||||
|
|
||||||
|
# Apply a preset to an existing project.
|
||||||
|
npx shadcn@latest apply a2r6bw
|
||||||
|
npx shadcn@latest apply a2r6bw --only theme
|
||||||
|
npx shadcn@latest apply a2r6bw --only font
|
||||||
|
npx shadcn@latest apply a2r6bw --only theme,font
|
||||||
|
|
||||||
|
# Inspect preset codes and project preset state.
|
||||||
|
npx shadcn@latest preset decode a2r6bw
|
||||||
|
npx shadcn@latest preset url a2r6bw
|
||||||
|
npx shadcn@latest preset open a2r6bw
|
||||||
|
npx shadcn@latest preset resolve
|
||||||
|
npx shadcn@latest preset resolve --json
|
||||||
|
|
||||||
|
# Add components.
|
||||||
|
npx shadcn@latest add button card dialog
|
||||||
|
npx shadcn@latest add @magicui/shimmer-button
|
||||||
|
npx shadcn@latest add owner/repo/item
|
||||||
|
npx shadcn@latest add --all
|
||||||
|
|
||||||
|
# Preview changes before adding/updating.
|
||||||
|
npx shadcn@latest add button --dry-run
|
||||||
|
npx shadcn@latest add button --diff button.tsx
|
||||||
|
npx shadcn@latest add @acme/form --view button.tsx
|
||||||
|
npx shadcn@latest add owner/repo/item --dry-run
|
||||||
|
|
||||||
|
# Search registries.
|
||||||
|
npx shadcn@latest search @shadcn -q "sidebar"
|
||||||
|
npx shadcn@latest search @tailark -q "stats"
|
||||||
|
npx shadcn@latest search owner/repo -q "login"
|
||||||
|
npx shadcn@latest search # all configured registries
|
||||||
|
npx shadcn@latest search @shadcn -q "menu" -t ui # filter by item type
|
||||||
|
|
||||||
|
# Get component docs and example URLs.
|
||||||
|
npx shadcn@latest docs button dialog select
|
||||||
|
|
||||||
|
# View registry item details (for items not yet installed).
|
||||||
|
npx shadcn@latest view @shadcn/button
|
||||||
|
npx shadcn@latest view owner/repo/item
|
||||||
|
```
|
||||||
|
|
||||||
|
**Named presets:** `nova`, `vega`, `maia`, `lyra`, `mira`, `luma`
|
||||||
|
**Templates:** `next`, `vite`, `start`, `react-router`, `astro` (all support `--monorepo`) and `laravel` (not supported for monorepo)
|
||||||
|
**Preset codes:** Version-prefixed base62 strings (e.g. `a2r6bw` or `b0`), from [ui.shadcn.com](https://ui.shadcn.com).
|
||||||
|
|
||||||
|
## Detailed References
|
||||||
|
|
||||||
|
- [rules/forms.md](./rules/forms.md) — FieldGroup, Field, InputGroup, ToggleGroup, FieldSet, validation states
|
||||||
|
- [rules/composition.md](./rules/composition.md) — Groups, overlays, Card, Tabs, Avatar, Alert, Empty, Toast, Separator, Skeleton, Badge, Button loading
|
||||||
|
- [rules/chat.md](./rules/chat.md) — MessageScroller, Message, Bubble, Attachment, Marker; streaming, anchoring, jump-to-latest
|
||||||
|
- [rules/icons.md](./rules/icons.md) — data-icon, icon sizing, passing icons as objects
|
||||||
|
- [rules/styling.md](./rules/styling.md) — Semantic colors, variants, className, spacing, size, truncate, dark mode, cn(), z-index
|
||||||
|
- [rules/base-vs-radix.md](./rules/base-vs-radix.md) — asChild vs render, Select, ToggleGroup, Slider, Accordion
|
||||||
|
- [cli.md](./cli.md) — Commands, flags, presets, templates
|
||||||
|
- [registry.md](./registry.md) — Authoring source registries, `include`, item definitions, dependencies, GitHub registry rules
|
||||||
|
- [customization.md](./customization.md) — Theming, CSS variables, extending components
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "shadcn/ui"
|
||||||
|
short_description: "Manages shadcn/ui components — adding, searching, fixing, debugging, styling, and composing UI."
|
||||||
|
icon_small: "./assets/shadcn-small.png"
|
||||||
|
icon_large: "./assets/shadcn.png"
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,290 @@
|
|||||||
|
# shadcn CLI Reference
|
||||||
|
|
||||||
|
Configuration is read from `components.json`.
|
||||||
|
|
||||||
|
> **IMPORTANT:** Always run commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest`. Check `packageManager` from project context to choose the right one. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
|
||||||
|
|
||||||
|
> **IMPORTANT:** Only use the flags documented below. Do not invent or guess flags — if a flag isn't listed here, it doesn't exist. The CLI auto-detects the package manager from the project's lockfile; there is no `--package-manager` flag.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- Commands: init, apply, add (dry-run, smart merge), search, view, docs, info, build
|
||||||
|
- Templates: next, vite, start, react-router, astro
|
||||||
|
- Presets: named, code, URL formats and fields
|
||||||
|
- Switching presets
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### `init` — Initialize or create a project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest init [components...] [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Initializes shadcn/ui in an existing project or creates a new project (when `--name` is provided). Optionally installs components in the same step.
|
||||||
|
|
||||||
|
| Flag | Short | Description | Default |
|
||||||
|
| ----------------------- | ----- | --------------------------------------------------------- | ------- |
|
||||||
|
| `--template <template>` | `-t` | Template (next, start, vite, next-monorepo, react-router) | — |
|
||||||
|
| `--preset [name]` | `-p` | Preset configuration (named, code, or URL) | — |
|
||||||
|
| `--yes` | `-y` | Skip confirmation prompt | `true` |
|
||||||
|
| `--defaults` | `-d` | Use defaults (`--template=next --preset=base-nova`) | `false` |
|
||||||
|
| `--force` | `-f` | Force overwrite existing configuration | `false` |
|
||||||
|
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||||
|
| `--name <name>` | `-n` | Name for new project | — |
|
||||||
|
| `--silent` | `-s` | Mute output | `false` |
|
||||||
|
| `--rtl` | | Enable RTL support | — |
|
||||||
|
| `--reinstall` | | Re-install existing UI components | `false` |
|
||||||
|
| `--monorepo` | | Scaffold a monorepo project | — |
|
||||||
|
| `--no-monorepo` | | Skip the monorepo prompt | — |
|
||||||
|
|
||||||
|
`npx shadcn@latest create` is an alias for `npx shadcn@latest init`.
|
||||||
|
|
||||||
|
### `apply` — Apply a preset to an existing project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest apply [preset] [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Applies a preset to an existing project, overwriting preset-driven config, fonts, CSS variables, and detected UI components.
|
||||||
|
|
||||||
|
| Flag | Short | Description | Default |
|
||||||
|
| ------------------- | ----- | ------------------------------------------ | ------- |
|
||||||
|
| `--preset <preset>` | — | Preset configuration (named, code, or URL) | — |
|
||||||
|
| `--yes` | `-y` | Skip confirmation prompt | `false` |
|
||||||
|
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||||
|
| `--silent` | `-s` | Mute output | `false` |
|
||||||
|
|
||||||
|
`[preset]` is a shorthand for `--preset <preset>`. If both are provided, they must match.
|
||||||
|
If no preset is provided, the CLI offers to open the custom preset builder on `ui.shadcn.com/create`.
|
||||||
|
|
||||||
|
### `add` — Add components
|
||||||
|
|
||||||
|
> **IMPORTANT:** To compare local components against upstream or to preview changes, ALWAYS use `npx shadcn@latest add <component> --dry-run`, `--diff`, or `--view`. NEVER fetch raw files from GitHub or other sources manually. The CLI handles registry resolution, file paths, and CSS diffing automatically.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add [components...] [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Accepts component names, registry-prefixed names (`@magicui/shimmer-button`),
|
||||||
|
GitHub item addresses (`owner/repo/item`), URLs, or local paths.
|
||||||
|
|
||||||
|
| Flag | Short | Description | Default |
|
||||||
|
| --------------- | ----- | -------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||||
|
| `--yes` | `-y` | Skip confirmation prompt | `false` |
|
||||||
|
| `--overwrite` | `-o` | Overwrite existing files | `false` |
|
||||||
|
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||||
|
| `--all` | `-a` | Add all available components | `false` |
|
||||||
|
| `--path <path>` | `-p` | Target path for the component | — |
|
||||||
|
| `--silent` | `-s` | Mute output | `false` |
|
||||||
|
| `--dry-run` | | Preview all changes without writing files | `false` |
|
||||||
|
| `--diff [path]` | | Show diffs. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | — |
|
||||||
|
| `--view [path]` | | Show file contents. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | — |
|
||||||
|
|
||||||
|
#### Dry-Run Mode
|
||||||
|
|
||||||
|
Use `--dry-run` to preview what `add` would do without writing any files. `--diff` and `--view` both imply `--dry-run`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Preview all changes.
|
||||||
|
npx shadcn@latest add button --dry-run
|
||||||
|
|
||||||
|
# Show diffs for all files (top 5).
|
||||||
|
npx shadcn@latest add button --diff
|
||||||
|
|
||||||
|
# Show the diff for a specific file.
|
||||||
|
npx shadcn@latest add button --diff button.tsx
|
||||||
|
|
||||||
|
# Show contents for all files (top 5).
|
||||||
|
npx shadcn@latest add button --view
|
||||||
|
|
||||||
|
# Show the full content of a specific file.
|
||||||
|
npx shadcn@latest add button --view button.tsx
|
||||||
|
|
||||||
|
# Works with URLs too.
|
||||||
|
npx shadcn@latest add https://api.npoint.io/abc123 --dry-run
|
||||||
|
|
||||||
|
# Works with public GitHub registries too.
|
||||||
|
npx shadcn@latest add owner/repo/item --dry-run
|
||||||
|
|
||||||
|
# CSS diffs.
|
||||||
|
npx shadcn@latest add button --diff globals.css
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use dry-run:**
|
||||||
|
|
||||||
|
- When the user asks "what files will this add?" or "what will this change?" — use `--dry-run`.
|
||||||
|
- Before overwriting existing components — use `--diff` to preview the changes first.
|
||||||
|
- When the user wants to inspect component source code without installing — use `--view`.
|
||||||
|
- When checking what CSS changes would be made to `globals.css` — use `--diff globals.css`.
|
||||||
|
- When the user asks to review or audit third-party registry code before installing — use `--view` to inspect the source.
|
||||||
|
|
||||||
|
> **`npx shadcn@latest add --dry-run` vs `npx shadcn@latest view`:** Prefer `npx shadcn@latest add --dry-run/--diff/--view` over `npx shadcn@latest view` when the user wants to preview changes to their project. `npx shadcn@latest view` only shows raw registry metadata. `npx shadcn@latest add --dry-run` shows exactly what would happen in the user's project: resolved file paths, diffs against existing files, and CSS updates. Use `npx shadcn@latest view` only when the user wants to browse registry info without a project context.
|
||||||
|
|
||||||
|
#### Smart Merge from Upstream
|
||||||
|
|
||||||
|
See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full workflow.
|
||||||
|
|
||||||
|
### `search` — Search registries
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest search [registries...] [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Fuzzy search across registries. Also aliased as `npx shadcn@latest list`.
|
||||||
|
Supports namespaces (`@acme`), public GitHub registry sources (`owner/repo`),
|
||||||
|
and registry catalog URLs. Without `-q`, lists all items. When no registries are
|
||||||
|
passed, searches every registry configured in `components.json`.
|
||||||
|
|
||||||
|
| Flag | Short | Description | Default |
|
||||||
|
| ------------------- | ----- | ------------------------------------------------- | ------- |
|
||||||
|
| `--query <query>` | `-q` | Search query | — |
|
||||||
|
| `--type <type>` | `-t` | Filter by item type (e.g. `ui`, `block`, `hook`); comma-separated | — |
|
||||||
|
| `--limit <number>` | `-l` | Max items to display | `100` |
|
||||||
|
| `--offset <number>` | `-o` | Items to skip | `0` |
|
||||||
|
| `--json` | | Output as JSON | `false` |
|
||||||
|
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||||
|
|
||||||
|
### `view` — View item details
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest view <items...> [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Displays item info including file contents. Examples:
|
||||||
|
`npx shadcn@latest view @shadcn/button`,
|
||||||
|
`npx shadcn@latest view owner/repo/item`.
|
||||||
|
|
||||||
|
### `docs` — Get component documentation URLs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest docs <components...> [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Outputs resolved URLs for component documentation, examples, and API references. Accepts one or more component names. Fetch the URLs to get the actual content.
|
||||||
|
|
||||||
|
Example output for `npx shadcn@latest docs input button`:
|
||||||
|
|
||||||
|
```
|
||||||
|
base radix
|
||||||
|
|
||||||
|
input
|
||||||
|
docs https://ui.shadcn.com/docs/components/radix/input
|
||||||
|
examples https://raw.githubusercontent.com/.../examples/input-example.tsx
|
||||||
|
|
||||||
|
button
|
||||||
|
docs https://ui.shadcn.com/docs/components/radix/button
|
||||||
|
examples https://raw.githubusercontent.com/.../examples/button-example.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Some components include an `api` link to the underlying library (e.g. `cmdk` for the command component).
|
||||||
|
|
||||||
|
### `diff` — Check for updates
|
||||||
|
|
||||||
|
Do not use this command. Use `npx shadcn@latest add --diff` instead.
|
||||||
|
|
||||||
|
### `info` — Project information
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest info [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Displays project info and `components.json` configuration. Run this first to discover the project's framework, aliases, Tailwind version, and resolved paths.
|
||||||
|
|
||||||
|
| Flag | Short | Description | Default |
|
||||||
|
| ------------- | ----- | ----------------- | ------- |
|
||||||
|
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||||
|
|
||||||
|
**Project Info fields:**
|
||||||
|
|
||||||
|
| Field | Type | Meaning |
|
||||||
|
| -------------------- | --------- | ------------------------------------------------------------------ |
|
||||||
|
| `framework` | `string` | Detected framework (`next`, `vite`, `react-router`, `start`, etc.) |
|
||||||
|
| `frameworkVersion` | `string` | Framework version (e.g. `15.2.4`) |
|
||||||
|
| `isSrcDir` | `boolean` | Whether the project uses a `src/` directory |
|
||||||
|
| `isRSC` | `boolean` | Whether React Server Components are enabled |
|
||||||
|
| `isTsx` | `boolean` | Whether the project uses TypeScript |
|
||||||
|
| `tailwindVersion` | `string` | `"v3"` or `"v4"` |
|
||||||
|
| `tailwindConfigFile` | `string` | Path to the Tailwind config file |
|
||||||
|
| `tailwindCssFile` | `string` | Path to the global CSS file |
|
||||||
|
| `aliasPrefix` | `string` | Import alias prefix (e.g. `@`, `~`, `@/`) |
|
||||||
|
| `packageManager` | `string` | Detected package manager (`npm`, `pnpm`, `yarn`, `bun`) |
|
||||||
|
|
||||||
|
**Components.json fields:**
|
||||||
|
|
||||||
|
| Field | Type | Meaning |
|
||||||
|
| -------------------- | --------- | ------------------------------------------------------------------------------------------ |
|
||||||
|
| `base` | `string` | Primitive library (`radix` or `base`) — determines component APIs and available props |
|
||||||
|
| `style` | `string` | Visual style (e.g. `nova`, `vega`) |
|
||||||
|
| `rsc` | `boolean` | RSC flag from config |
|
||||||
|
| `tsx` | `boolean` | TypeScript flag |
|
||||||
|
| `tailwind.config` | `string` | Tailwind config path |
|
||||||
|
| `tailwind.css` | `string` | Global CSS path — this is where custom CSS variables go |
|
||||||
|
| `iconLibrary` | `string` | Icon library — determines icon import package (e.g. `lucide-react`, `@tabler/icons-react`) |
|
||||||
|
| `aliases.components` | `string` | Component import alias (e.g. `@/components`) |
|
||||||
|
| `aliases.utils` | `string` | Utils import alias (e.g. `@/lib/utils`) |
|
||||||
|
| `aliases.ui` | `string` | UI component alias (e.g. `@/components/ui`) |
|
||||||
|
| `aliases.lib` | `string` | Lib alias (e.g. `@/lib`) |
|
||||||
|
| `aliases.hooks` | `string` | Hooks alias (e.g. `@/hooks`) |
|
||||||
|
| `resolvedPaths` | `object` | Absolute file-system paths for each alias |
|
||||||
|
| `registries` | `object` | Configured custom registries |
|
||||||
|
|
||||||
|
**Links fields:**
|
||||||
|
|
||||||
|
The `info` output includes a **Links** section with templated URLs for component docs, source, and examples. For resolved URLs, use `npx shadcn@latest docs <component>` instead.
|
||||||
|
|
||||||
|
### `build` — Build a custom registry
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest build [registry] [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Builds `registry.json` into individual JSON files for distribution. Default input: `./registry.json`, default output: `./public/r`.
|
||||||
|
|
||||||
|
For authoring rules, `include`, item definitions, `registryDependencies`, and
|
||||||
|
GitHub registry behavior, see [registry.md](./registry.md).
|
||||||
|
|
||||||
|
| Flag | Short | Description | Default |
|
||||||
|
| ----------------- | ----- | ----------------- | ------------ |
|
||||||
|
| `--output <path>` | `-o` | Output directory | `./public/r` |
|
||||||
|
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Templates
|
||||||
|
|
||||||
|
| Value | Framework | Monorepo support |
|
||||||
|
| -------------- | -------------- | ---------------- |
|
||||||
|
| `next` | Next.js | Yes |
|
||||||
|
| `vite` | Vite | Yes |
|
||||||
|
| `start` | TanStack Start | Yes |
|
||||||
|
| `react-router` | React Router | Yes |
|
||||||
|
| `astro` | Astro | Yes |
|
||||||
|
| `laravel` | Laravel | No |
|
||||||
|
|
||||||
|
All templates support monorepo scaffolding via the `--monorepo` flag. When passed, the CLI uses a monorepo-specific template directory (e.g. `next-monorepo`, `vite-monorepo`). When neither `--monorepo` nor `--no-monorepo` is passed, the CLI prompts interactively. Laravel does not support monorepo scaffolding.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Presets
|
||||||
|
|
||||||
|
Three ways to specify a preset via `--preset`:
|
||||||
|
|
||||||
|
1. **Named:** `--preset nova` or `--preset lyra`
|
||||||
|
2. **Code:** `--preset a2r6bw` (version-prefixed base62 string, e.g. `a2r6bw` or `b0`)
|
||||||
|
3. **URL:** `--preset "https://ui.shadcn.com/init?base=radix&style=nova&..."`
|
||||||
|
|
||||||
|
> **IMPORTANT:** Never try to decode, fetch, or resolve preset codes manually. Preset codes are opaque — pass them directly to `npx shadcn@latest init --preset <code>` and let the CLI handle resolution.
|
||||||
|
> Use `npx shadcn@latest apply --preset <code>` when overwriting an existing project's preset.
|
||||||
|
|
||||||
|
## Switching Presets
|
||||||
|
|
||||||
|
Ask the user first: **overwrite**, **merge**, or **skip** existing components?
|
||||||
|
|
||||||
|
- **Overwrite / Re-install** → `npx shadcn@latest apply --preset <code>`. Overwrites all detected component files with the new preset styles. Use when the user hasn't customized components.
|
||||||
|
- **Merge** → `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to get the list of installed components and use the [smart merge workflow](./SKILL.md#updating-components) to update them one by one, preserving local changes. Use when the user has customized components.
|
||||||
|
- **Skip** → `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS variables, leaves existing components as-is.
|
||||||
|
|
||||||
|
Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
# Customization & Theming
|
||||||
|
|
||||||
|
Components reference semantic CSS variable tokens. Change the variables to change every component.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- How it works (CSS variables → Tailwind utilities → components)
|
||||||
|
- Color variables and OKLCH format
|
||||||
|
- Dark mode setup
|
||||||
|
- Changing the theme (presets, CSS variables)
|
||||||
|
- Adding custom colors (Tailwind v3 and v4)
|
||||||
|
- Border radius
|
||||||
|
- Customizing components (variants, className, wrappers)
|
||||||
|
- Checking for updates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. CSS variables defined in `:root` (light) and `.dark` (dark mode).
|
||||||
|
2. Tailwind maps them to utilities: `bg-primary`, `text-muted-foreground`, etc.
|
||||||
|
3. Components use these utilities — changing a variable changes all components that reference it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Color Variables
|
||||||
|
|
||||||
|
Every color follows the `name` / `name-foreground` convention. The base variable is for backgrounds, `-foreground` is for text/icons on that background.
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
| -------------------------------------------- | -------------------------------- |
|
||||||
|
| `--background` / `--foreground` | Page background and default text |
|
||||||
|
| `--card` / `--card-foreground` | Card surfaces |
|
||||||
|
| `--primary` / `--primary-foreground` | Primary buttons and actions |
|
||||||
|
| `--secondary` / `--secondary-foreground` | Secondary actions |
|
||||||
|
| `--muted` / `--muted-foreground` | Muted/disabled states |
|
||||||
|
| `--accent` / `--accent-foreground` | Hover and accent states |
|
||||||
|
| `--destructive` / `--destructive-foreground` | Error and destructive actions |
|
||||||
|
| `--border` | Default border color |
|
||||||
|
| `--input` | Form input borders |
|
||||||
|
| `--ring` | Focus ring color |
|
||||||
|
| `--chart-1` through `--chart-5` | Chart/data visualization |
|
||||||
|
| `--sidebar-*` | Sidebar-specific colors |
|
||||||
|
| `--surface` / `--surface-foreground` | Secondary surface |
|
||||||
|
|
||||||
|
Colors use OKLCH: `--primary: oklch(0.205 0 0)` where values are lightness (0–1), chroma (0 = gray), and hue (0–360).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dark Mode
|
||||||
|
|
||||||
|
Class-based toggle via `.dark` on the root element. In Next.js, use `next-themes`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ThemeProvider } from "next-themes"
|
||||||
|
|
||||||
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
|
{children}
|
||||||
|
</ThemeProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changing the Theme
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Apply a preset code from ui.shadcn.com.
|
||||||
|
npx shadcn@latest apply --preset a2r6bw
|
||||||
|
|
||||||
|
# Positional shorthand also works.
|
||||||
|
npx shadcn@latest apply a2r6bw
|
||||||
|
|
||||||
|
# Switch to a named preset and overwrite existing components.
|
||||||
|
npx shadcn@latest apply --preset nova
|
||||||
|
|
||||||
|
# Preserve existing components instead.
|
||||||
|
npx shadcn@latest init --preset nova --force --no-reinstall
|
||||||
|
|
||||||
|
# Use a custom theme URL.
|
||||||
|
npx shadcn@latest apply --preset "https://ui.shadcn.com/init?base=radix&style=nova&theme=blue&..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Or edit CSS variables directly in `globals.css`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding Custom Colors
|
||||||
|
|
||||||
|
Add variables to the file at `tailwindCssFile` from `npx shadcn@latest info` (typically `globals.css`). Never create a new CSS file for this.
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 1. Define in the global CSS file. */
|
||||||
|
:root {
|
||||||
|
--warning: oklch(0.84 0.16 84);
|
||||||
|
--warning-foreground: oklch(0.28 0.07 46);
|
||||||
|
}
|
||||||
|
.dark {
|
||||||
|
--warning: oklch(0.41 0.11 46);
|
||||||
|
--warning-foreground: oklch(0.99 0.02 95);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 2a. Register with Tailwind v4 (@theme inline). */
|
||||||
|
@theme inline {
|
||||||
|
--color-warning: var(--warning);
|
||||||
|
--color-warning-foreground: var(--warning-foreground);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When `tailwindVersion` is `"v3"` (check via `npx shadcn@latest info`), register in `tailwind.config.js` instead:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 2b. Register with Tailwind v3 (tailwind.config.js).
|
||||||
|
module.exports = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
warning: "oklch(var(--warning) / <alpha-value>)",
|
||||||
|
"warning-foreground":
|
||||||
|
"oklch(var(--warning-foreground) / <alpha-value>)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 3. Use in components.
|
||||||
|
<div className="bg-warning text-warning-foreground">Warning</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Border Radius
|
||||||
|
|
||||||
|
`--radius` controls border radius globally. Components derive values from it (`rounded-lg` = `var(--radius)`, `rounded-md` = `calc(var(--radius) - 2px)`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Customizing Components
|
||||||
|
|
||||||
|
See also: [rules/styling.md](./rules/styling.md) for Incorrect/Correct examples.
|
||||||
|
|
||||||
|
Prefer these approaches in order:
|
||||||
|
|
||||||
|
### 1. Built-in variants
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Click
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Tailwind classes via `className`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Card className="mx-auto max-w-md">...</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Add a new variant
|
||||||
|
|
||||||
|
Edit the component source to add a variant via `cva`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/ui/button.tsx
|
||||||
|
warning: "bg-warning text-warning-foreground hover:bg-warning/90",
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Wrapper components
|
||||||
|
|
||||||
|
Compose shadcn/ui primitives into higher-level components:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export function ConfirmDialog({ title, description, onConfirm, children }) {
|
||||||
|
return (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={onConfirm}>Confirm</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checking for Updates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add button --diff
|
||||||
|
```
|
||||||
|
|
||||||
|
To preview exactly what would change before updating, use `--dry-run` and `--diff`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add button --dry-run # see all affected files
|
||||||
|
npx shadcn@latest add button --diff button.tsx # see the diff for a specific file
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full smart merge workflow.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
{
|
||||||
|
"skill_name": "shadcn",
|
||||||
|
"evals": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"prompt": "I'm building a Next.js app with shadcn/ui (base-nova preset, lucide icons). Create a settings form component with fields for: full name, email address, and notification preferences (email, SMS, push notifications as toggle options). Add validation states for required fields.",
|
||||||
|
"expected_output": "A React component using FieldGroup, Field, ToggleGroup, data-invalid/aria-invalid validation, gap-* spacing, and semantic colors.",
|
||||||
|
"files": [],
|
||||||
|
"expectations": [
|
||||||
|
"Uses FieldGroup and Field components for form layout instead of raw div with space-y",
|
||||||
|
"Uses Switch for independent on/off notification toggles (not looping Button with manual active state)",
|
||||||
|
"Uses data-invalid on Field and aria-invalid on the input control for validation states",
|
||||||
|
"Uses gap-* (e.g. gap-4, gap-6) instead of space-y-* or space-x-* for spacing",
|
||||||
|
"Uses semantic color tokens (e.g. bg-background, text-muted-foreground, text-destructive) instead of raw colors like bg-red-500",
|
||||||
|
"No manual dark: color overrides"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"prompt": "Create a dialog component for editing a user profile. It should have the user's avatar at the top, input fields for name and bio, and Save/Cancel buttons with appropriate icons. Using shadcn/ui with radix-nova preset and tabler icons.",
|
||||||
|
"expected_output": "A React component with DialogTitle, Avatar+AvatarFallback, data-icon on icon buttons, no icon sizing classes, tabler icon imports.",
|
||||||
|
"files": [],
|
||||||
|
"expectations": [
|
||||||
|
"Includes DialogTitle for accessibility (visible or with sr-only class)",
|
||||||
|
"Avatar component includes AvatarFallback",
|
||||||
|
"Icons on buttons use the data-icon attribute (data-icon=\"inline-start\" or data-icon=\"inline-end\")",
|
||||||
|
"No sizing classes on icons inside components (no size-4, w-4, h-4, etc.)",
|
||||||
|
"Uses tabler icons (@tabler/icons-react) instead of lucide-react",
|
||||||
|
"Uses asChild for custom triggers (radix preset)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"prompt": "Create a dashboard component that shows 4 stat cards in a grid. Each card has a title, large number, percentage change badge, and a loading skeleton state. Using shadcn/ui with base-nova preset and lucide icons.",
|
||||||
|
"expected_output": "A React component with full Card composition, Skeleton for loading, Badge for changes, semantic colors, gap-* spacing.",
|
||||||
|
"files": [],
|
||||||
|
"expectations": [
|
||||||
|
"Uses full Card composition with CardHeader, CardTitle, CardContent (not dumping everything into CardContent)",
|
||||||
|
"Uses Skeleton component for loading placeholders instead of custom animate-pulse divs",
|
||||||
|
"Uses Badge component for percentage change instead of custom styled spans",
|
||||||
|
"Uses semantic color tokens instead of raw color values like bg-green-500 or text-red-600",
|
||||||
|
"Uses gap-* instead of space-y-* or space-x-* for spacing",
|
||||||
|
"Uses size-* when width and height are equal instead of separate w-* h-*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"prompt": "I'm building a Next.js app with shadcn/ui (base-nova preset, lucide icons). Build a chat conversation view: a scrollable thread of messages from two different people, each with an avatar, sender name, timestamp, and message bubble. A couple of messages include an image attachment and a PDF file attachment, and there's a 'Today' divider separating the days.",
|
||||||
|
"expected_output": "A React component composing MessageScroller, Message, Bubble, Attachment, and Marker from the registry instead of hand-rolled bubble/divider/attachment markup.",
|
||||||
|
"files": [],
|
||||||
|
"expectations": [
|
||||||
|
"Uses MessageScroller (MessageScrollerProvider, MessageScrollerViewport, MessageScrollerContent, MessageScrollerItem) for the scrollable thread instead of a raw overflow-y-auto div or ScrollArea",
|
||||||
|
"Wraps each row in MessageScrollerItem inside MessageScrollerContent",
|
||||||
|
"Uses Message with MessageAvatar/MessageContent/MessageHeader for row layout instead of custom flex divs",
|
||||||
|
"Uses Bubble + BubbleContent for the message surface instead of a styled div with bg-muted/bg-primary",
|
||||||
|
"Uses Attachment (AttachmentMedia, AttachmentContent, AttachmentTitle, AttachmentDescription) for the file and image attachments instead of Item or a custom card",
|
||||||
|
"Uses Marker (variant=\"separator\") for the 'Today' divider instead of Separator plus a centered label",
|
||||||
|
"Uses semantic color tokens and gap-* spacing; no raw colors like bg-emerald-500 and no space-y-*",
|
||||||
|
"Includes \"use client\" when the component uses state or event handlers (isRSC)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"prompt": "Using shadcn/ui (base-nova preset, lucide icons), build a streaming AI chat UI. The assistant's reply streams in while it generates, the view auto-scrolls to follow the latest content but stops following if the user scrolls up to read earlier messages, a 'jump to latest' button appears when the user has scrolled away from the bottom, and a subtle 'thinking…' shimmer shows while the model is generating.",
|
||||||
|
"expected_output": "A React component that delegates scroll/anchor behavior to MessageScroller and uses MessageScrollerButton for jump-to-latest and the shimmer utility for the thinking indicator — no hand-rolled scroll logic or custom shimmer keyframes.",
|
||||||
|
"files": [],
|
||||||
|
"expectations": [
|
||||||
|
"Uses MessageScroller with MessageScrollerProvider (autoScroll) and scrollAnchor on message items for the stick-to-bottom/follow behavior instead of a custom useStickToBottom hook or ResizeObserver/scrollTop wiring",
|
||||||
|
"Uses MessageScrollerButton for the jump-to-latest control instead of a hand-built conditional button driven by manual scroll-position state",
|
||||||
|
"Uses the shimmer utility class for the 'thinking…' indicator instead of a custom @keyframes or bg-clip-text gradient animation",
|
||||||
|
"Wraps each message row in MessageScrollerItem inside MessageScrollerContent",
|
||||||
|
"Uses Message + Bubble + BubbleContent for the conversation rows instead of hand-rolled bubble divs",
|
||||||
|
"Uses semantic color tokens and gap-* spacing; includes \"use client\" (isRSC)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# shadcn MCP Server
|
||||||
|
|
||||||
|
The CLI includes an MCP server that lets AI assistants search, browse, view, and install items from registries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
shadcn mcp # start the MCP server (stdio)
|
||||||
|
shadcn mcp init # write config for your editor
|
||||||
|
```
|
||||||
|
|
||||||
|
Editor config files:
|
||||||
|
|
||||||
|
| Editor | Config file |
|
||||||
|
| ----------- | ------------------------------- |
|
||||||
|
| Claude Code | `.mcp.json` |
|
||||||
|
| Cursor | `.cursor/mcp.json` |
|
||||||
|
| VS Code | `.vscode/mcp.json` |
|
||||||
|
| OpenCode | `opencode.json` |
|
||||||
|
| Codex | `~/.codex/config.toml` (manual) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
> **Tip:** MCP tools handle registry operations (search, view, install). For project configuration (aliases, framework, Tailwind version), use `npx shadcn@latest info` — there is no MCP equivalent.
|
||||||
|
|
||||||
|
### `shadcn:get_project_registries`
|
||||||
|
|
||||||
|
Returns registry names from `components.json`. Errors if no `components.json` exists.
|
||||||
|
|
||||||
|
**Input:** none
|
||||||
|
|
||||||
|
### `shadcn:list_items_in_registries`
|
||||||
|
|
||||||
|
Lists all items from one or more registries. Registries can be configured
|
||||||
|
namespaces such as `@acme`, public GitHub sources such as `owner/repo`, or
|
||||||
|
registry catalog URLs. Omit `registries` to list from every registry configured
|
||||||
|
in `components.json`.
|
||||||
|
|
||||||
|
**Input:** `registries` (string[], optional — omit for all configured), `types` (string[], optional — e.g. `["ui", "block"]`), `limit` (number, optional, defaults to 100), `offset` (number, optional)
|
||||||
|
|
||||||
|
### `shadcn:search_items_in_registries`
|
||||||
|
|
||||||
|
Fuzzy search across registries. Registries can be configured namespaces, public
|
||||||
|
GitHub sources, or registry catalog URLs. Omit `registries` to search every
|
||||||
|
registry configured in `components.json` — e.g. "find me a hero" across all
|
||||||
|
configured registries.
|
||||||
|
|
||||||
|
**Input:** `registries` (string[], optional — omit for all configured), `query` (string), `types` (string[], optional — e.g. `["ui", "block"]`), `limit` (number, optional, defaults to 100), `offset` (number, optional)
|
||||||
|
|
||||||
|
### `shadcn:view_items_in_registries`
|
||||||
|
|
||||||
|
View item details including full file contents.
|
||||||
|
|
||||||
|
**Input:** `items` (string[]) — e.g.
|
||||||
|
`["@shadcn/button", "@shadcn/card", "owner/repo/item"]`
|
||||||
|
|
||||||
|
### `shadcn:get_item_examples_from_registries`
|
||||||
|
|
||||||
|
Find usage examples and demos with source code. Omit `registries` to search
|
||||||
|
every registry configured in `components.json`.
|
||||||
|
|
||||||
|
**Input:** `registries` (string[], optional — omit for all configured), `query` (string) — e.g. `"accordion-demo"`, `"button example"`
|
||||||
|
|
||||||
|
### `shadcn:get_add_command_for_items`
|
||||||
|
|
||||||
|
Returns the CLI install command.
|
||||||
|
|
||||||
|
**Input:** `items` (string[]) — e.g. `["@shadcn/button"]`
|
||||||
|
|
||||||
|
### `shadcn:get_audit_checklist`
|
||||||
|
|
||||||
|
Returns a checklist for verifying components (imports, deps, lint, TypeScript).
|
||||||
|
|
||||||
|
**Input:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuring Registries
|
||||||
|
|
||||||
|
Namespaced and authenticated registries are set in `components.json`. The
|
||||||
|
`@shadcn` registry is always built-in. Public GitHub registries can also be used
|
||||||
|
directly as `owner/repo` registry sources when the repository has a root
|
||||||
|
`registry.json`; they do not need `components.json` configuration.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"registries": {
|
||||||
|
"@acme": "https://acme.com/r/{name}.json",
|
||||||
|
"@private": {
|
||||||
|
"url": "https://private.com/r/{name}.json",
|
||||||
|
"headers": { "Authorization": "Bearer ${MY_TOKEN}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Names must start with `@`.
|
||||||
|
- URLs must contain `{name}`.
|
||||||
|
- `${VAR}` references are resolved from environment variables.
|
||||||
|
|
||||||
|
Community registry index: `https://ui.shadcn.com/r/registries.json`
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
# Registry Authoring and Addresses
|
||||||
|
|
||||||
|
Use this reference when the user wants to create, fix, publish, or reason about
|
||||||
|
a shadcn registry.
|
||||||
|
|
||||||
|
## Mental Model
|
||||||
|
|
||||||
|
A registry has two forms:
|
||||||
|
|
||||||
|
- **Source registry**: an authored `registry.json` in a project or repository.
|
||||||
|
It may use `include` and file paths that point at source files.
|
||||||
|
- **Built registry**: generated JSON files served to CLI consumers, usually
|
||||||
|
from `public/r`. Use `npx shadcn@latest build` to create this form.
|
||||||
|
|
||||||
|
The CLI installer consumes registry item payloads. A source registry is a way to
|
||||||
|
author those payloads from real files.
|
||||||
|
|
||||||
|
Registry items are not limited to React components. They can distribute
|
||||||
|
components, hooks, utilities, design tokens, pages, config files, docs, rules,
|
||||||
|
workflows, templates, MCP files, and other project files.
|
||||||
|
|
||||||
|
## Root `registry.json`
|
||||||
|
|
||||||
|
The root registry file should define registry metadata and either `items` or
|
||||||
|
`include`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||||
|
"name": "acme",
|
||||||
|
"homepage": "https://acme.com",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "absolute-url",
|
||||||
|
"type": "registry:lib",
|
||||||
|
"title": "Absolute URL",
|
||||||
|
"description": "A utility to turn any path into an absolute URL.",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "lib/absolute-url.ts",
|
||||||
|
"type": "registry:lib"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Root registry rules:
|
||||||
|
|
||||||
|
- Root `registry.json` must include `name` and `homepage`.
|
||||||
|
- `items` is an array of registry item definitions.
|
||||||
|
- `include` may be used to split the source registry into multiple files.
|
||||||
|
- Included registry files may omit `name` and `homepage`.
|
||||||
|
|
||||||
|
## Include
|
||||||
|
|
||||||
|
Use `include` to keep large registries modular.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||||
|
"name": "acme",
|
||||||
|
"homepage": "https://acme.com",
|
||||||
|
"include": ["registry/ui/registry.json", "registry/blocks/registry.json"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Include rules:
|
||||||
|
|
||||||
|
- Include paths are relative to the `registry.json` that declares them.
|
||||||
|
- Include paths must explicitly point to a `registry.json` file.
|
||||||
|
- Do not use remote URLs, absolute paths, or parent traversal (`..`).
|
||||||
|
- Item file paths are relative to the registry file that declares the item.
|
||||||
|
- Duplicate item names fail across the resolved registry.
|
||||||
|
|
||||||
|
Example included file:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "button",
|
||||||
|
"type": "registry:ui",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "button.tsx",
|
||||||
|
"type": "registry:ui"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If this file is at `registry/ui/registry.json`, then `button.tsx` is read from
|
||||||
|
`registry/ui/button.tsx`, and the built item path is emitted relative to the
|
||||||
|
root registry.
|
||||||
|
|
||||||
|
## Item Definitions
|
||||||
|
|
||||||
|
Common item fields:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "login-form",
|
||||||
|
"type": "registry:block",
|
||||||
|
"title": "Login Form",
|
||||||
|
"description": "A login form with email and password fields.",
|
||||||
|
"dependencies": ["zod"],
|
||||||
|
"registryDependencies": ["button", "input", "label"],
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "blocks/login-form.tsx",
|
||||||
|
"type": "registry:block"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cssVars": {
|
||||||
|
"light": {
|
||||||
|
"brand": "oklch(0.62 0.18 250)"
|
||||||
|
},
|
||||||
|
"dark": {
|
||||||
|
"brand": "oklch(0.72 0.16 250)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Important fields:
|
||||||
|
|
||||||
|
- `name`: the installable item name. It is not necessarily a file path.
|
||||||
|
- `type`: one of the registry item types, such as `registry:ui`,
|
||||||
|
`registry:block`, `registry:lib`, `registry:hook`, `registry:file`,
|
||||||
|
`registry:page`, `registry:theme`, `registry:style`, `registry:font`, or
|
||||||
|
`registry:item`.
|
||||||
|
- `files`: source files copied or generated by the item.
|
||||||
|
- `dependencies`: npm runtime dependencies.
|
||||||
|
- `devDependencies`: npm development dependencies.
|
||||||
|
- `registryDependencies`: other registry items required by this item.
|
||||||
|
- `cssVars`, `css`, `tailwind`, `envVars`, and `docs`: optional install-time
|
||||||
|
additions.
|
||||||
|
|
||||||
|
File rules:
|
||||||
|
|
||||||
|
- File paths are relative to the declaring `registry.json`.
|
||||||
|
- `registry:file` and `registry:page` files require a `target`.
|
||||||
|
- Do not use remote file URLs in source registry file paths.
|
||||||
|
- Keep source files copy-pasteable: no hidden app-only imports.
|
||||||
|
|
||||||
|
## Registry Dependencies
|
||||||
|
|
||||||
|
`registryDependencies` entries are item addresses, not file paths.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "login-form",
|
||||||
|
"type": "registry:block",
|
||||||
|
"registryDependencies": ["button", "@acme/input", "acme/ui/card#v1.2.0"],
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "blocks/login-form.tsx",
|
||||||
|
"type": "registry:block"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Dependency rules:
|
||||||
|
|
||||||
|
- Bare names such as `"button"` mean official shadcn items.
|
||||||
|
- Bare names never mean same-registry or same-repository items.
|
||||||
|
- Namespaced dependencies use `@namespace/item-name`.
|
||||||
|
- GitHub dependencies use `owner/repo/item-name`.
|
||||||
|
- Pin GitHub dependencies with `owner/repo/item-name#ref` when needed.
|
||||||
|
- Refs are not inherited. If `owner/repo/foo#v2` depends on `bar` from the same
|
||||||
|
repo at `v2`, write `owner/repo/bar#v2`.
|
||||||
|
- Do not use relative dependencies such as `"./bar"`.
|
||||||
|
|
||||||
|
## Address Schemes
|
||||||
|
|
||||||
|
When reasoning about a registry item string, classify it first.
|
||||||
|
|
||||||
|
| Address | Scheme | Meaning |
|
||||||
|
| ----------------------------------- | --------- | ------------------------------------------------------------ |
|
||||||
|
| `button` | shadcn | Official shadcn item named `button`. |
|
||||||
|
| `@acme/button` | namespace | Item `button` from configured registry `@acme`. |
|
||||||
|
| `@acme/ui/button` | namespace | Item `ui/button` from configured registry `@acme`. |
|
||||||
|
| `https://example.com/r/button.json` | url | Built registry item JSON at that URL. |
|
||||||
|
| `./button.json` | file | Built registry item JSON on disk. |
|
||||||
|
| `acme/ui/button` | github | Item `button` from GitHub repo `acme/ui`. |
|
||||||
|
| `acme/ui/forms/login#main` | github | Item `forms/login` from GitHub repo `acme/ui` at ref `main`. |
|
||||||
|
|
||||||
|
For namespace and GitHub addresses, slashful item names are allowed and are item
|
||||||
|
names, not file paths. Addresses ending in `.json` keep file-address
|
||||||
|
precedence, so `acme/ui/data/schema.json` is treated as a file path, not a
|
||||||
|
GitHub item address.
|
||||||
|
|
||||||
|
## GitHub Registries
|
||||||
|
|
||||||
|
A public GitHub repository can act as a source registry when it has a root
|
||||||
|
`registry.json`.
|
||||||
|
|
||||||
|
```txt
|
||||||
|
owner/repo/item-name[#ref]
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- The first two path segments are GitHub owner and repo.
|
||||||
|
- All remaining path segments are the registry item name.
|
||||||
|
- The source entrypoint is always root `registry.json`.
|
||||||
|
- GitHub registries are source registries consumed directly by the CLI. They do
|
||||||
|
not require `shadcn build` or generated item JSON files.
|
||||||
|
- `include` follows the same source-registry rules as local registries.
|
||||||
|
- Currently, GitHub addresses support public `github.com` repositories only.
|
||||||
|
- Private repos and GitHub Enterprise require explicit product decisions.
|
||||||
|
|
||||||
|
When implementing GitHub registry fetching, resolve refs to a commit SHA before
|
||||||
|
reading source files. Do not read moving refs directly from
|
||||||
|
`raw.githubusercontent.com`, because branch-like refs can be cached for several
|
||||||
|
minutes.
|
||||||
|
|
||||||
|
Preferred flow:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
owner/repo[#ref]
|
||||||
|
-> resolve ref with git ls-remote
|
||||||
|
-> commit SHA
|
||||||
|
-> read https://raw.githubusercontent.com/{owner}/{repo}/{sha}/registry.json
|
||||||
|
-> read includes and item files from the same SHA
|
||||||
|
```
|
||||||
|
|
||||||
|
This keeps a command on one consistent repository snapshot.
|
||||||
|
|
||||||
|
Full 40-character commit SHAs are already stable and can be used directly.
|
||||||
|
Branches, tags, and short refs require Git so the CLI can resolve them to a
|
||||||
|
commit SHA first.
|
||||||
|
|
||||||
|
## Build and Verify
|
||||||
|
|
||||||
|
Use the CLI to build source registries:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest build
|
||||||
|
npx shadcn@latest build registry.json --output public/r
|
||||||
|
```
|
||||||
|
|
||||||
|
Use CLI commands to inspect the result:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest list @acme
|
||||||
|
npx shadcn@latest search @acme -q "login"
|
||||||
|
npx shadcn@latest view @acme/login-form
|
||||||
|
npx shadcn@latest add @acme/login-form --dry-run
|
||||||
|
npx shadcn@latest registry validate ./registry.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Use GitHub addresses directly for public GitHub registries:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest list owner/repo
|
||||||
|
npx shadcn@latest search owner/repo -q "login"
|
||||||
|
npx shadcn@latest view owner/repo/item
|
||||||
|
npx shadcn@latest add owner/repo/item --dry-run
|
||||||
|
npx shadcn@latest registry validate owner/repo
|
||||||
|
```
|
||||||
|
|
||||||
|
When working on registry implementation in the shadcn/ui codebase:
|
||||||
|
|
||||||
|
- Keep address parsing pure and testable.
|
||||||
|
- Do not add side effects to validators.
|
||||||
|
- Preserve existing behavior for official shadcn, namespace, URL, and file
|
||||||
|
schemes.
|
||||||
|
- Add tests for address parsing, source loading, dependency resolution, list,
|
||||||
|
search, view, and add paths.
|
||||||
|
- Prefer small source-reader abstractions over a plugin system until there are
|
||||||
|
multiple real providers.
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
# Base vs Radix
|
||||||
|
|
||||||
|
API differences between `base` and `radix`. Check the `base` field from `npx shadcn@latest info`.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- Composition: asChild vs render
|
||||||
|
- Button / trigger as non-button element
|
||||||
|
- Select (items prop, placeholder, positioning, multiple, object values)
|
||||||
|
- ToggleGroup (type vs multiple)
|
||||||
|
- Slider (scalar vs array)
|
||||||
|
- Accordion (type and defaultValue)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Composition: asChild (radix) vs render (base)
|
||||||
|
|
||||||
|
Radix uses `asChild` to replace the default element. Base uses `render`. Don't wrap triggers in extra elements.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<DialogTrigger>
|
||||||
|
<div>
|
||||||
|
<Button>Open</Button>
|
||||||
|
</div>
|
||||||
|
</DialogTrigger>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (radix):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>Open</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<DialogTrigger render={<Button />}>Open</DialogTrigger>
|
||||||
|
```
|
||||||
|
|
||||||
|
This applies to all trigger and close components: `DialogTrigger`, `SheetTrigger`, `AlertDialogTrigger`, `DropdownMenuTrigger`, `PopoverTrigger`, `TooltipTrigger`, `CollapsibleTrigger`, `DialogClose`, `SheetClose`, `NavigationMenuLink`, `BreadcrumbLink`, `SidebarMenuButton`, `Badge`, `Item`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Button / trigger as non-button element (base only)
|
||||||
|
|
||||||
|
When `render` changes an element to a non-button (`<a>`, `<span>`), add `nativeButton={false}`.
|
||||||
|
|
||||||
|
**Incorrect (base):** missing `nativeButton={false}`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button render={<a href="/docs" />}>Read the docs</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button render={<a href="/docs" />} nativeButton={false}>
|
||||||
|
Read the docs
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (radix):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button asChild>
|
||||||
|
<a href="/docs">Read the docs</a>
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Same for triggers whose `render` is not a `Button`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// base.
|
||||||
|
<PopoverTrigger render={<InputGroupAddon />} nativeButton={false}>
|
||||||
|
Pick date
|
||||||
|
</PopoverTrigger>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Select
|
||||||
|
|
||||||
|
**items prop (base only).** Base requires an `items` prop on the root. Radix uses inline JSX only.
|
||||||
|
|
||||||
|
**Incorrect (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Select>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select a fruit" /></SelectTrigger>
|
||||||
|
</Select>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const items = [
|
||||||
|
{ label: "Select a fruit", value: null },
|
||||||
|
{ label: "Apple", value: "apple" },
|
||||||
|
{ label: "Banana", value: "banana" },
|
||||||
|
]
|
||||||
|
|
||||||
|
<Select items={items}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{items.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (radix):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Select>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a fruit" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="apple">Apple</SelectItem>
|
||||||
|
<SelectItem value="banana">Banana</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Placeholder.** Base uses a `{ value: null }` item in the items array. Radix uses `<SelectValue placeholder="...">`.
|
||||||
|
|
||||||
|
**Content positioning.** Base uses `alignItemWithTrigger`. Radix uses `position`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// base.
|
||||||
|
<SelectContent alignItemWithTrigger={false} side="bottom">
|
||||||
|
|
||||||
|
// radix.
|
||||||
|
<SelectContent position="popper">
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Select — multiple selection and object values (base only)
|
||||||
|
|
||||||
|
Base supports `multiple`, render-function children on `SelectValue`, and object values with `itemToStringValue`. Radix is single-select with string values only.
|
||||||
|
|
||||||
|
**Correct (base — multiple selection):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Select items={items} multiple defaultValue={[]}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue>
|
||||||
|
{(value: string[]) => value.length === 0 ? "Select fruits" : `${value.length} selected`}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
...
|
||||||
|
</Select>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (base — object values):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Select defaultValue={plans[0]} itemToStringValue={(plan) => plan.name}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue>{(value) => value.name}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
...
|
||||||
|
</Select>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ToggleGroup
|
||||||
|
|
||||||
|
Base uses a `multiple` boolean prop. Radix uses `type="single"` or `type="multiple"`.
|
||||||
|
|
||||||
|
**Incorrect (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ToggleGroup type="single" defaultValue="daily">
|
||||||
|
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Single (no prop needed), defaultValue is always an array.
|
||||||
|
<ToggleGroup defaultValue={["daily"]} spacing={2}>
|
||||||
|
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
|
||||||
|
// Multi-selection.
|
||||||
|
<ToggleGroup multiple>
|
||||||
|
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (radix):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Single, defaultValue is a string.
|
||||||
|
<ToggleGroup type="single" defaultValue="daily" spacing={2}>
|
||||||
|
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
|
||||||
|
// Multi-selection.
|
||||||
|
<ToggleGroup type="multiple">
|
||||||
|
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Controlled single value:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// base — wrap/unwrap arrays.
|
||||||
|
const [value, setValue] = React.useState("normal")
|
||||||
|
<ToggleGroup value={[value]} onValueChange={(v) => setValue(v[0])}>
|
||||||
|
|
||||||
|
// radix — plain string.
|
||||||
|
const [value, setValue] = React.useState("normal")
|
||||||
|
<ToggleGroup type="single" value={value} onValueChange={setValue}>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slider
|
||||||
|
|
||||||
|
Base accepts a plain number for a single thumb. Radix always requires an array.
|
||||||
|
|
||||||
|
**Incorrect (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Slider defaultValue={[50]} max={100} step={1} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Slider defaultValue={50} max={100} step={1} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (radix):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Slider defaultValue={[50]} max={100} step={1} />
|
||||||
|
```
|
||||||
|
|
||||||
|
Both use arrays for range sliders. Controlled `onValueChange` in base may need a cast:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// base.
|
||||||
|
const [value, setValue] = React.useState([0.3, 0.7])
|
||||||
|
<Slider value={value} onValueChange={(v) => setValue(v as number[])} />
|
||||||
|
|
||||||
|
// radix.
|
||||||
|
const [value, setValue] = React.useState([0.3, 0.7])
|
||||||
|
<Slider value={value} onValueChange={setValue} />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accordion
|
||||||
|
|
||||||
|
Radix requires `type="single"` or `type="multiple"` and supports `collapsible`. `defaultValue` is a string. Base uses no `type` prop, uses `multiple` boolean, and `defaultValue` is always an array.
|
||||||
|
|
||||||
|
**Incorrect (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Accordion type="single" collapsible defaultValue="item-1">
|
||||||
|
<AccordionItem value="item-1">...</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Accordion defaultValue={["item-1"]}>
|
||||||
|
<AccordionItem value="item-1">...</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
// Multi-select.
|
||||||
|
<Accordion multiple defaultValue={["item-1", "item-2"]}>
|
||||||
|
<AccordionItem value="item-1">...</AccordionItem>
|
||||||
|
<AccordionItem value="item-2">...</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (radix):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Accordion type="single" collapsible defaultValue="item-1">
|
||||||
|
<AccordionItem value="item-1">...</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
```
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
# Chat & Messaging
|
||||||
|
|
||||||
|
Components for conversation and chat UI. Compose these instead of hand-rolling
|
||||||
|
bubbles, scroll containers, dividers, or attachment cards.
|
||||||
|
|
||||||
|
Install: `npx shadcn@latest add message-scroller message bubble attachment marker`
|
||||||
|
|
||||||
|
The same component names and props ship for both `base` and `radix`; only
|
||||||
|
composition differs (`render` vs `asChild`). See [base-vs-radix.md](./base-vs-radix.md).
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- Scrollable threads use MessageScroller
|
||||||
|
- Message rows use Message
|
||||||
|
- Message surfaces use Bubble
|
||||||
|
- Attachments use Attachment
|
||||||
|
- System notes and dividers use Marker
|
||||||
|
- Streaming, anchoring, and jump-to-latest are built in
|
||||||
|
- Escape hatch: the scroller hooks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scrollable threads use MessageScroller
|
||||||
|
|
||||||
|
A conversation that scrolls, follows new messages, restores position, or jumps
|
||||||
|
to a message uses `MessageScroller`. Don't build a raw overflow container with
|
||||||
|
manual scroll wiring, and don't reach for `ScrollArea`.
|
||||||
|
|
||||||
|
The parts nest in a fixed order. Every direct child of the content is wrapped in
|
||||||
|
a `MessageScrollerItem` so the scroller can measure, anchor, preserve position,
|
||||||
|
track visibility, and jump to it. `MessageScrollerButton` sits inside
|
||||||
|
`MessageScroller`, after the viewport.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Hand-rolled scroll container with manual stick-to-bottom logic.
|
||||||
|
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto">
|
||||||
|
<div className="flex flex-col gap-6 p-4">
|
||||||
|
{messages.map((m) => (
|
||||||
|
<ChatMessage key={m.id} message={m} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<MessageScrollerProvider autoScroll>
|
||||||
|
<MessageScroller>
|
||||||
|
<MessageScrollerViewport>
|
||||||
|
<MessageScrollerContent>
|
||||||
|
{messages.map((message) => (
|
||||||
|
<MessageScrollerItem
|
||||||
|
key={message.id}
|
||||||
|
messageId={message.id}
|
||||||
|
scrollAnchor={message.role === "user"}
|
||||||
|
>
|
||||||
|
<Message align={message.role === "user" ? "end" : "start"}>
|
||||||
|
{/* ...message content... */}
|
||||||
|
</Message>
|
||||||
|
</MessageScrollerItem>
|
||||||
|
))}
|
||||||
|
</MessageScrollerContent>
|
||||||
|
</MessageScrollerViewport>
|
||||||
|
<MessageScrollerButton />
|
||||||
|
</MessageScroller>
|
||||||
|
</MessageScrollerProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Message rows use Message
|
||||||
|
|
||||||
|
`Message` lays out a single row: avatar, header, content, footer, with
|
||||||
|
alignment. Group consecutive rows from one sender with `MessageGroup`. Don't
|
||||||
|
rebuild the row from flex divs.
|
||||||
|
|
||||||
|
`align="end"` is the current user's side; `align="start"` is everyone else.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Message align="start">
|
||||||
|
<MessageAvatar>
|
||||||
|
<Avatar>
|
||||||
|
<AvatarImage src={sender.avatar} alt={sender.name} />
|
||||||
|
<AvatarFallback>{initials}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</MessageAvatar>
|
||||||
|
<MessageContent>
|
||||||
|
<MessageHeader>{sender.name}</MessageHeader>
|
||||||
|
<Bubble>
|
||||||
|
<BubbleContent>{text}</BubbleContent>
|
||||||
|
</Bubble>
|
||||||
|
<MessageFooter>{time}</MessageFooter>
|
||||||
|
</MessageContent>
|
||||||
|
</Message>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Message surfaces use Bubble
|
||||||
|
|
||||||
|
The colored message surface is `Bubble` + `BubbleContent`, never a styled `div`
|
||||||
|
with `bg-muted` / `bg-primary` and hand-managed corners.
|
||||||
|
|
||||||
|
- `variant`: `default`, `secondary`, `muted`, `tinted`, `outline`, `ghost`, `destructive`.
|
||||||
|
- `align`: `start` or `end` (matches the `Message` side).
|
||||||
|
|
||||||
|
`BubbleReactions` renders the reaction cluster. `side` (`top` | `bottom`) and
|
||||||
|
`align` (`start` | `end`) position it against the bubble. Don't lay reactions out
|
||||||
|
with absolutely-positioned `Badge`s.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="w-fit rounded-2xl bg-primary px-3 py-2 text-primary-foreground">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Bubble variant="default" align="end">
|
||||||
|
<BubbleContent>{text}</BubbleContent>
|
||||||
|
<BubbleReactions side="bottom" align="end">
|
||||||
|
<Badge variant="secondary">👍 2</Badge>
|
||||||
|
</BubbleReactions>
|
||||||
|
</Bubble>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Attachments use Attachment
|
||||||
|
|
||||||
|
File and image attachments use `Attachment`, not `Item` or a custom card. It
|
||||||
|
carries upload state, so wire `state` to the real status rather than rendering a
|
||||||
|
separate spinner.
|
||||||
|
|
||||||
|
- `state`: `idle`, `uploading`, `processing`, `error`, `done`. `uploading` and
|
||||||
|
`processing` apply the `shimmer` animation to the title automatically.
|
||||||
|
- `size`: `default`, `sm`, `xs`. `orientation`: `horizontal`, `vertical`.
|
||||||
|
- Use `AttachmentGroup` to lay out several attachments in a scrolling row.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Attachment state="done">
|
||||||
|
<AttachmentMedia variant="icon">
|
||||||
|
<FileTextIcon />
|
||||||
|
</AttachmentMedia>
|
||||||
|
<AttachmentContent>
|
||||||
|
<AttachmentTitle>homepage-feedback.pdf</AttachmentTitle>
|
||||||
|
<AttachmentDescription>PDF · 2.4 MB</AttachmentDescription>
|
||||||
|
</AttachmentContent>
|
||||||
|
<AttachmentActions>
|
||||||
|
<AttachmentAction>
|
||||||
|
<DownloadIcon />
|
||||||
|
</AttachmentAction>
|
||||||
|
</AttachmentActions>
|
||||||
|
</Attachment>
|
||||||
|
```
|
||||||
|
|
||||||
|
For an image, use `<AttachmentMedia variant="image">` with an `img` child.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## System notes and dividers use Marker
|
||||||
|
|
||||||
|
Status lines ("Sarah joined the conversation"), date dividers ("Today"), and
|
||||||
|
labeled separators are `Marker`, not a `Separator` plus a centered span.
|
||||||
|
|
||||||
|
- `variant`: `default` (plain row), `separator` (centered label with rules on
|
||||||
|
each side), `border` (bottom-bordered row).
|
||||||
|
- `MarkerIcon` holds a leading icon; `MarkerContent` holds the label.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="flex items-center gap-3 py-2">
|
||||||
|
<Separator className="flex-1" />
|
||||||
|
<span className="text-xs text-muted-foreground">Today</span>
|
||||||
|
<Separator className="flex-1" />
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Marker variant="separator">
|
||||||
|
<MarkerContent>Today</MarkerContent>
|
||||||
|
</Marker>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Streaming, anchoring, and jump-to-latest are built in
|
||||||
|
|
||||||
|
`MessageScroller` handles the behavior that chat UIs usually reinvent. Don't
|
||||||
|
write a `useStickToBottom` hook, a `ResizeObserver`, or manual `scrollTop` math.
|
||||||
|
|
||||||
|
- **Follow the live edge while streaming.** `MessageScrollerProvider` with
|
||||||
|
`autoScroll` keeps the view pinned to new content and yields the moment the
|
||||||
|
user scrolls up. Streaming token updates that grow the last message are
|
||||||
|
followed automatically.
|
||||||
|
- **Anchor a turn.** `scrollAnchor` on a `MessageScrollerItem` marks the row to
|
||||||
|
hold in view (typically the user's message that started the turn).
|
||||||
|
- **Jump to latest.** `MessageScrollerButton` appears when the user scrolls away
|
||||||
|
and scrolls back on click. `direction="end"` (default) or `direction="start"`.
|
||||||
|
It is a self-managing control, so don't gate it behind your own scroll-position
|
||||||
|
state.
|
||||||
|
|
||||||
|
For a "thinking…" indicator while the model generates, apply the `shimmer`
|
||||||
|
utility to text. Don't author a custom keyframe animation. See
|
||||||
|
[styling.md](./styling.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Escape hatch: the scroller hooks
|
||||||
|
|
||||||
|
For behavior the parts don't expose, read state from the hooks rather than
|
||||||
|
re-implementing the scroller: `useMessageScroller`,
|
||||||
|
`useMessageScrollerVisibility`, and `useMessageScrollerScrollable`. They come
|
||||||
|
from the auto-installed `@shadcn/react` dependency, so there's nothing extra to
|
||||||
|
install. Reach for them only when composition can't express what you need.
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
# Component Composition
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- Items always inside their Group component
|
||||||
|
- Callouts use Alert
|
||||||
|
- Empty states use Empty component
|
||||||
|
- Toast notifications follow the project base
|
||||||
|
- Choosing between overlay components
|
||||||
|
- Dialog, Sheet, and Drawer always need a Title
|
||||||
|
- Card structure
|
||||||
|
- Button has no isPending or isLoading prop
|
||||||
|
- TabsTrigger must be inside TabsList
|
||||||
|
- Avatar always needs AvatarFallback
|
||||||
|
- Use Separator instead of raw hr or border divs
|
||||||
|
- Use Skeleton for loading placeholders
|
||||||
|
- Use Badge instead of custom styled spans
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Items always inside their Group component
|
||||||
|
|
||||||
|
Never render items directly inside the content container.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="apple">Apple</SelectItem>
|
||||||
|
<SelectItem value="banana">Banana</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="apple">Apple</SelectItem>
|
||||||
|
<SelectItem value="banana">Banana</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
```
|
||||||
|
|
||||||
|
This applies to all group-based components:
|
||||||
|
|
||||||
|
| Item | Group |
|
||||||
|
|------|-------|
|
||||||
|
| `SelectItem`, `SelectLabel` | `SelectGroup` |
|
||||||
|
| `DropdownMenuItem`, `DropdownMenuLabel`, `DropdownMenuSub` | `DropdownMenuGroup` |
|
||||||
|
| `MenubarItem` | `MenubarGroup` |
|
||||||
|
| `ContextMenuItem` | `ContextMenuGroup` |
|
||||||
|
| `CommandItem` | `CommandGroup` |
|
||||||
|
| `MessageScrollerItem` | `MessageScrollerContent` |
|
||||||
|
| `Message` (consecutive, same sender) | `MessageGroup` |
|
||||||
|
| `Bubble` (stacked) | `BubbleGroup` |
|
||||||
|
| `Attachment` (in a row) | `AttachmentGroup` |
|
||||||
|
|
||||||
|
Chat components nest in a fixed order (`MessageScrollerProvider` → `MessageScroller` → `MessageScrollerViewport` → `MessageScrollerContent` → `MessageScrollerItem`). See [chat.md](./chat.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Callouts use Alert
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Alert>
|
||||||
|
<AlertTitle>Warning</AlertTitle>
|
||||||
|
<AlertDescription>Something needs attention.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Empty states use Empty component
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Empty>
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyMedia variant="icon"><FolderIcon /></EmptyMedia>
|
||||||
|
<EmptyTitle>No projects yet</EmptyTitle>
|
||||||
|
<EmptyDescription>Get started by creating a new project.</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
<EmptyContent>
|
||||||
|
<Button>Create Project</Button>
|
||||||
|
</EmptyContent>
|
||||||
|
</Empty>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Toast notifications follow the project base
|
||||||
|
|
||||||
|
For Base UI projects, use the `toast` component:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
title: "Changes saved.",
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
For Radix and React Aria projects, use Sonner:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
toast.success("Changes saved.")
|
||||||
|
toast.error("Something went wrong.")
|
||||||
|
toast("File deleted.", {
|
||||||
|
action: { label: "Undo", onClick: () => undoDelete() },
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Choosing between overlay components
|
||||||
|
|
||||||
|
| Use case | Component |
|
||||||
|
|----------|-----------|
|
||||||
|
| Focused task that requires input | `Dialog` |
|
||||||
|
| Destructive action confirmation | `AlertDialog` |
|
||||||
|
| Side panel with details or filters | `Sheet` |
|
||||||
|
| Mobile-first bottom panel | `Drawer` |
|
||||||
|
| Quick info on hover | `HoverCard` |
|
||||||
|
| Small contextual content on click | `Popover` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dialog, Sheet, and Drawer always need a Title
|
||||||
|
|
||||||
|
`DialogTitle`, `SheetTitle`, `DrawerTitle` are required for accessibility. Use `className="sr-only"` if visually hidden.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Profile</DialogTitle>
|
||||||
|
<DialogDescription>Update your profile.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
...
|
||||||
|
</DialogContent>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Card structure
|
||||||
|
|
||||||
|
Use full composition — don't dump everything into `CardContent`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Team Members</CardTitle>
|
||||||
|
<CardDescription>Manage your team.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>...</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button>Invite</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Button has no isPending or isLoading prop
|
||||||
|
|
||||||
|
Compose with `Spinner` + `data-icon` + `disabled`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button disabled>
|
||||||
|
<Spinner data-icon="inline-start" />
|
||||||
|
Saving...
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TabsTrigger must be inside TabsList
|
||||||
|
|
||||||
|
Never render `TabsTrigger` directly inside `Tabs` — always wrap in `TabsList`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Tabs defaultValue="account">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="account">Account</TabsTrigger>
|
||||||
|
<TabsTrigger value="password">Password</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="account">...</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Avatar always needs AvatarFallback
|
||||||
|
|
||||||
|
Always include `AvatarFallback` for when the image fails to load:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Avatar>
|
||||||
|
<AvatarImage src="/avatar.png" alt="User" />
|
||||||
|
<AvatarFallback>JD</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use existing components instead of custom markup
|
||||||
|
|
||||||
|
| Instead of | Use |
|
||||||
|
|---|---|
|
||||||
|
| `<hr>` or `<div className="border-t">` | `<Separator />` |
|
||||||
|
| `<div className="animate-pulse">` with styled divs | `<Skeleton className="h-4 w-3/4" />` |
|
||||||
|
| `<span className="rounded-full bg-green-100 ...">` | `<Badge variant="secondary">` |
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
# Forms & Inputs
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- Forms use FieldGroup + Field
|
||||||
|
- InputGroup requires InputGroupInput/InputGroupTextarea
|
||||||
|
- Buttons inside inputs use InputGroup + InputGroupAddon
|
||||||
|
- Option sets (2–7 choices) use ToggleGroup
|
||||||
|
- FieldSet + FieldLegend for grouping related fields
|
||||||
|
- Field validation and disabled states
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Forms use FieldGroup + Field
|
||||||
|
|
||||||
|
Always use `FieldGroup` + `Field` — never raw `div` with `space-y-*`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||||
|
<Input id="email" type="email" />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="password">Password</FieldLabel>
|
||||||
|
<Input id="password" type="password" />
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `Field orientation="horizontal"` for settings pages. Use `FieldLabel className="sr-only"` for visually hidden labels.
|
||||||
|
|
||||||
|
**Choosing form controls:**
|
||||||
|
|
||||||
|
- Simple text input → `Input`
|
||||||
|
- Dropdown with predefined options → `Select`
|
||||||
|
- Searchable dropdown → `Combobox`
|
||||||
|
- Native HTML select (no JS) → `native-select`
|
||||||
|
- Boolean toggle → `Switch` (for settings) or `Checkbox` (for forms)
|
||||||
|
- Single choice from few options → `RadioGroup`
|
||||||
|
- Toggle between 2–5 options → `ToggleGroup` + `ToggleGroupItem`
|
||||||
|
- OTP/verification code → `InputOTP`
|
||||||
|
- Multi-line text → `Textarea`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## InputGroup requires InputGroupInput/InputGroupTextarea
|
||||||
|
|
||||||
|
Never use raw `Input` or `Textarea` inside an `InputGroup`.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<InputGroup>
|
||||||
|
<Input placeholder="Search..." />
|
||||||
|
</InputGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { InputGroup, InputGroupInput } from "@/components/ui/input-group"
|
||||||
|
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupInput placeholder="Search..." />
|
||||||
|
</InputGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Buttons inside inputs use InputGroup + InputGroupAddon
|
||||||
|
|
||||||
|
Never place a `Button` directly inside or adjacent to an `Input` with custom positioning.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="relative">
|
||||||
|
<Input placeholder="Search..." className="pr-10" />
|
||||||
|
<Button className="absolute right-0 top-0" size="icon">
|
||||||
|
<SearchIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { InputGroup, InputGroupInput, InputGroupAddon } from "@/components/ui/input-group"
|
||||||
|
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupInput placeholder="Search..." />
|
||||||
|
<InputGroupAddon>
|
||||||
|
<Button size="icon">
|
||||||
|
<SearchIcon data-icon="inline-start" />
|
||||||
|
</Button>
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Option sets (2–7 choices) use ToggleGroup
|
||||||
|
|
||||||
|
Don't manually loop `Button` components with active state.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [selected, setSelected] = useState("daily")
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{["daily", "weekly", "monthly"].map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option}
|
||||||
|
variant={selected === option ? "default" : "outline"}
|
||||||
|
onClick={() => setSelected(option)}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||||
|
|
||||||
|
<ToggleGroup spacing={2}>
|
||||||
|
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="monthly">Monthly</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
Combine with `Field` for labelled toggle groups:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<FieldTitle id="theme-label">Theme</FieldTitle>
|
||||||
|
<ToggleGroup aria-labelledby="theme-label" spacing={2}>
|
||||||
|
<ToggleGroupItem value="light">Light</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="dark">Dark</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="system">System</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
</Field>
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** `defaultValue` and `type`/`multiple` props differ between base and radix. See [base-vs-radix.md](./base-vs-radix.md#togglegroup).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FieldSet + FieldLegend for grouping related fields
|
||||||
|
|
||||||
|
Use `FieldSet` + `FieldLegend` for related checkboxes, radios, or switches — not `div` with a heading:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<FieldSet>
|
||||||
|
<FieldLegend variant="label">Preferences</FieldLegend>
|
||||||
|
<FieldDescription>Select all that apply.</FieldDescription>
|
||||||
|
<FieldGroup className="gap-3">
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<Checkbox id="dark" />
|
||||||
|
<FieldLabel htmlFor="dark" className="font-normal">Dark mode</FieldLabel>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</FieldSet>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Field validation and disabled states
|
||||||
|
|
||||||
|
Both attributes are needed — `data-invalid`/`data-disabled` styles the field (label, description), while `aria-invalid`/`disabled` styles the control.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Invalid.
|
||||||
|
<Field data-invalid>
|
||||||
|
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||||
|
<Input id="email" aria-invalid />
|
||||||
|
<FieldDescription>Invalid email address.</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
// Disabled.
|
||||||
|
<Field data-disabled>
|
||||||
|
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||||
|
<Input id="email" disabled />
|
||||||
|
</Field>
|
||||||
|
```
|
||||||
|
|
||||||
|
Works for all controls: `Input`, `Textarea`, `Select`, `Checkbox`, `RadioGroupItem`, `Switch`, `Slider`, `NativeSelect`, `InputOTP`.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Icons
|
||||||
|
|
||||||
|
**Always use the project's configured `iconLibrary` for imports.** Check the `iconLibrary` field from project context: `lucide` → `lucide-react`, `tabler` → `@tabler/icons-react`, etc. Never assume `lucide-react`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Icons in Button use data-icon attribute
|
||||||
|
|
||||||
|
Add `data-icon="inline-start"` (prefix) or `data-icon="inline-end"` (suffix) to the icon. No sizing classes on the icon.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button>
|
||||||
|
<SearchIcon className="mr-2 size-4" />
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button>
|
||||||
|
<SearchIcon data-icon="inline-start"/>
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button>
|
||||||
|
Next
|
||||||
|
<ArrowRightIcon data-icon="inline-end"/>
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## No sizing classes on icons inside components
|
||||||
|
|
||||||
|
Components handle icon sizing via CSS. Don't add `size-4`, `w-4 h-4`, or other sizing classes to icons inside `Button`, `DropdownMenuItem`, `Alert`, `Sidebar*`, or other shadcn components. Unless the user explicitly asks for custom icon sizes.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button>
|
||||||
|
<SearchIcon className="size-4" data-icon="inline-start" />
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<SettingsIcon className="mr-2 size-4" />
|
||||||
|
Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button>
|
||||||
|
<SearchIcon data-icon="inline-start" />
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<SettingsIcon />
|
||||||
|
Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pass icons as component objects, not string keys
|
||||||
|
|
||||||
|
Use `icon={CheckIcon}`, not a string key to a lookup map.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const iconMap = {
|
||||||
|
check: CheckIcon,
|
||||||
|
alert: AlertIcon,
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ icon }: { icon: string }) {
|
||||||
|
const Icon = iconMap[icon]
|
||||||
|
return <Icon />
|
||||||
|
}
|
||||||
|
|
||||||
|
<StatusBadge icon="check" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Import from the project's configured iconLibrary (e.g. lucide-react, @tabler/icons-react).
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function StatusBadge({ icon: Icon }: { icon: React.ComponentType }) {
|
||||||
|
return <Icon />
|
||||||
|
}
|
||||||
|
|
||||||
|
<StatusBadge icon={CheckIcon} />
|
||||||
|
```
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
# Styling & Customization
|
||||||
|
|
||||||
|
See [customization.md](../customization.md) for theming, CSS variables, and adding custom colors.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- Semantic colors
|
||||||
|
- Built-in variants first
|
||||||
|
- className for layout only
|
||||||
|
- No space-x-* / space-y-*
|
||||||
|
- Prefer size-* over w-* h-* when equal
|
||||||
|
- Prefer truncate shorthand
|
||||||
|
- No manual dark: color overrides
|
||||||
|
- Use cn() for conditional classes
|
||||||
|
- No manual z-index on overlay components
|
||||||
|
- Use shimmer / scroll-fade utilities, not custom animations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Semantic colors
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="bg-blue-500 text-white">
|
||||||
|
<p className="text-gray-600">Secondary text</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="bg-primary text-primary-foreground">
|
||||||
|
<p className="text-muted-foreground">Secondary text</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## No raw color values for status/state indicators
|
||||||
|
|
||||||
|
For positive, negative, or status indicators, use Badge variants, semantic tokens like `text-destructive`, or define custom CSS variables — don't reach for raw Tailwind colors.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<span className="text-emerald-600">+20.1%</span>
|
||||||
|
<span className="text-green-500">Active</span>
|
||||||
|
<span className="text-red-600">-3.2%</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Badge variant="secondary">+20.1%</Badge>
|
||||||
|
<Badge>Active</Badge>
|
||||||
|
<span className="text-destructive">-3.2%</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
If you need a success/positive color that doesn't exist as a semantic token, use a Badge variant or ask the user about adding a custom CSS variable to the theme (see [customization.md](../customization.md)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Built-in variants first
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button className="border border-input bg-transparent hover:bg-accent">
|
||||||
|
Click me
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button variant="outline">Click me</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## className for layout only
|
||||||
|
|
||||||
|
Use `className` for layout (e.g. `max-w-md`, `mx-auto`, `mt-4`), **not** for overriding component colors or typography. To change colors, use semantic tokens, built-in variants, or CSS variables.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Card className="bg-blue-100 text-blue-900 font-bold">
|
||||||
|
<CardContent>Dashboard</CardContent>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Card className="max-w-md mx-auto">
|
||||||
|
<CardContent>Dashboard</CardContent>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
To customize a component's appearance, prefer these approaches in order:
|
||||||
|
1. **Built-in variants** — `variant="outline"`, `variant="destructive"`, etc.
|
||||||
|
2. **Semantic color tokens** — `bg-primary`, `text-muted-foreground`.
|
||||||
|
3. **CSS variables** — define custom colors in the global CSS file (see [customization.md](../customization.md)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## No space-x-* / space-y-*
|
||||||
|
|
||||||
|
Use `gap-*` instead. `space-y-4` → `flex flex-col gap-4`. `space-x-2` → `flex gap-2`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Input />
|
||||||
|
<Input />
|
||||||
|
<Button>Submit</Button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prefer size-* over w-* h-* when equal
|
||||||
|
|
||||||
|
`size-10` not `w-10 h-10`. Applies to icons, avatars, skeletons, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prefer truncate shorthand
|
||||||
|
|
||||||
|
`truncate` not `overflow-hidden text-ellipsis whitespace-nowrap`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## No manual dark: color overrides
|
||||||
|
|
||||||
|
Use semantic tokens — they handle light/dark via CSS variables. `bg-background text-foreground` not `bg-white dark:bg-gray-950`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use cn() for conditional classes
|
||||||
|
|
||||||
|
Use the `cn()` utility from the project for conditional or merged class names. Don't write manual ternaries in className strings.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className={`flex items-center ${isActive ? "bg-primary text-primary-foreground" : "bg-muted"}`}>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
<div className={cn("flex items-center", isActive ? "bg-primary text-primary-foreground" : "bg-muted")}>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## No manual z-index on overlay components
|
||||||
|
|
||||||
|
`Dialog`, `Sheet`, `Drawer`, `AlertDialog`, `DropdownMenu`, `Popover`, `Tooltip`, `HoverCard` handle their own stacking. Never add `z-50` or `z-[999]`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use shimmer / scroll-fade utilities, not custom animations
|
||||||
|
|
||||||
|
For a live "thinking…" or loading-text shimmer, apply the `shimmer` utility. Don't author a custom `@keyframes` or a `bg-clip-text` gradient sweep.
|
||||||
|
|
||||||
|
For scroll-aware edge fading on a scroll container, use `scroll-fade` (and the axis variants `scroll-fade-x` / `scroll-fade-b`). Don't hand-roll mask gradients. The chat components already apply these internally: `Attachment` shimmers its title during upload, and `MessageScrollerViewport` fades its edges.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<span className="animate-pulse bg-gradient-to-r from-muted-foreground/40 via-foreground/70 to-muted-foreground/40 bg-clip-text text-transparent [animation:shimmer_1.6s_infinite]">
|
||||||
|
Thinking…
|
||||||
|
</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<span className="shimmer">Thinking…</span>
|
||||||
|
```
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"sessionId":"879f34f3-9715-423d-aee2-535ba3a8accd","pid":14552,"acquiredAt":1787669985270}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
---
|
||||||
|
name: shadcn
|
||||||
|
description: Manages shadcn components and projects — adding, searching, fixing, debugging, styling, and composing UI, including chat interfaces. Provides project context, component docs, and usage examples. Applies when working with shadcn/ui, component registries, presets, --preset codes, or any project with a components.json file. Also triggers for "shadcn init", "create an app with --preset", or "switch to --preset".
|
||||||
|
user-invocable: false
|
||||||
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
|
---
|
||||||
|
|
||||||
|
# shadcn/ui
|
||||||
|
|
||||||
|
A framework for building ui, components and design systems. Components are added as source code to the user's project via the CLI.
|
||||||
|
|
||||||
|
> **IMPORTANT:** Run all CLI commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest` — based on the project's `packageManager`. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
|
||||||
|
|
||||||
|
## Current Project Context
|
||||||
|
|
||||||
|
```json
|
||||||
|
!`npx shadcn@latest info --json`
|
||||||
|
```
|
||||||
|
|
||||||
|
The JSON above contains the project config and installed components. Use `npx shadcn@latest docs <component>` to get documentation and example URLs for any component.
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
|
||||||
|
1. **Use existing components first.** Use `npx shadcn@latest search` to check registries before writing custom UI. Check community registries too.
|
||||||
|
2. **Compose, don't reinvent.** Settings page = Tabs + Card + form controls. Dashboard = Sidebar + Card + Chart + Table.
|
||||||
|
3. **Use built-in variants before custom styles.** `variant="outline"`, `size="sm"`, etc.
|
||||||
|
4. **Use semantic colors.** `bg-primary`, `text-muted-foreground` — never raw values like `bg-blue-500`.
|
||||||
|
|
||||||
|
## Critical Rules
|
||||||
|
|
||||||
|
These rules are **always enforced**. Each links to a file with Incorrect/Correct code pairs.
|
||||||
|
|
||||||
|
### Styling & Tailwind → [styling.md](./rules/styling.md)
|
||||||
|
|
||||||
|
- **`className` for layout, not styling.** Never override component colors or typography.
|
||||||
|
- **No `space-x-*` or `space-y-*`.** Use `flex` with `gap-*`. For vertical stacks, `flex flex-col gap-*`.
|
||||||
|
- **Use `size-*` when width and height are equal.** `size-10` not `w-10 h-10`.
|
||||||
|
- **Use `truncate` shorthand.** Not `overflow-hidden text-ellipsis whitespace-nowrap`.
|
||||||
|
- **No manual `dark:` color overrides.** Use semantic tokens (`bg-background`, `text-muted-foreground`).
|
||||||
|
- **Use `cn()` for conditional classes.** Don't write manual template literal ternaries.
|
||||||
|
- **No manual `z-index` on overlay components.** Dialog, Sheet, Popover, etc. handle their own stacking.
|
||||||
|
|
||||||
|
### Forms & Inputs → [forms.md](./rules/forms.md)
|
||||||
|
|
||||||
|
- **Forms use `FieldGroup` + `Field`.** Never use raw `div` with `space-y-*` or `grid gap-*` for form layout.
|
||||||
|
- **`InputGroup` uses `InputGroupInput`/`InputGroupTextarea`.** Never raw `Input`/`Textarea` inside `InputGroup`.
|
||||||
|
- **Buttons inside inputs use `InputGroup` + `InputGroupAddon`.**
|
||||||
|
- **Option sets (2–7 choices) use `ToggleGroup`.** Don't loop `Button` with manual active state.
|
||||||
|
- **`FieldSet` + `FieldLegend` for grouping related checkboxes/radios.** Don't use a `div` with a heading.
|
||||||
|
- **Field validation uses `data-invalid` + `aria-invalid`.** `data-invalid` on `Field`, `aria-invalid` on the control. For disabled: `data-disabled` on `Field`, `disabled` on the control.
|
||||||
|
|
||||||
|
### Component Structure → [composition.md](./rules/composition.md)
|
||||||
|
|
||||||
|
- **Items always inside their Group.** `SelectItem` → `SelectGroup`. `DropdownMenuItem` → `DropdownMenuGroup`. `CommandItem` → `CommandGroup`.
|
||||||
|
- **Use `asChild` (radix) or `render` (base) for custom triggers.** Check `base` field from `npx shadcn@latest info`. → [base-vs-radix.md](./rules/base-vs-radix.md)
|
||||||
|
- **Dialog, Sheet, and Drawer always need a Title.** `DialogTitle`, `SheetTitle`, `DrawerTitle` required for accessibility. Use `className="sr-only"` if visually hidden.
|
||||||
|
- **Use full Card composition.** `CardHeader`/`CardTitle`/`CardDescription`/`CardContent`/`CardFooter`. Don't dump everything in `CardContent`.
|
||||||
|
- **Button has no `isPending`/`isLoading`.** Compose with `Spinner` + `data-icon` + `disabled`.
|
||||||
|
- **`TabsTrigger` must be inside `TabsList`.** Never render triggers directly in `Tabs`.
|
||||||
|
- **`Avatar` always needs `AvatarFallback`.** For when the image fails to load.
|
||||||
|
|
||||||
|
### Use Components, Not Custom Markup → [composition.md](./rules/composition.md)
|
||||||
|
|
||||||
|
- **Use existing components before custom markup.** Check if a component exists before writing a styled `div`.
|
||||||
|
- **Callouts use `Alert`.** Don't build custom styled divs.
|
||||||
|
- **Empty states use `Empty`.** Don't build custom empty state markup.
|
||||||
|
- **Toast follows the project base.** Use `toast` from the `toast` component for
|
||||||
|
Base UI projects. Use `toast()` from `sonner` for Radix and React Aria
|
||||||
|
projects.
|
||||||
|
- **Use `Separator`** instead of `<hr>` or `<div className="border-t">`.
|
||||||
|
- **Use `Skeleton`** for loading placeholders. No custom `animate-pulse` divs.
|
||||||
|
- **Use `Badge`** instead of custom styled spans.
|
||||||
|
|
||||||
|
### Icons → [icons.md](./rules/icons.md)
|
||||||
|
|
||||||
|
- **Icons in `Button` use `data-icon`.** `data-icon="inline-start"` or `data-icon="inline-end"` on the icon.
|
||||||
|
- **No sizing classes on icons inside components.** Components handle icon sizing via CSS. No `size-4` or `w-4 h-4`.
|
||||||
|
- **Pass icons as objects, not string keys.** `icon={CheckIcon}`, not a string lookup.
|
||||||
|
|
||||||
|
### Chat & Messaging → [chat.md](./rules/chat.md)
|
||||||
|
|
||||||
|
- **Chat UI composes the chat primitives.** Conversations use `MessageScroller`, rows use `Message`, surfaces use `Bubble`. Never hand-rolled bubble `div`s or a raw scroll container.
|
||||||
|
- **`MessageScroller` owns scroll behavior.** Streaming follow, anchoring, and jump-to-latest (`MessageScrollerButton`) are built in. Don't write a `useStickToBottom`/`ResizeObserver` hook.
|
||||||
|
- **Attachments use `Attachment`; system notes and dividers use `Marker`.** Not `Item` cards or `Separator` + a label.
|
||||||
|
|
||||||
|
### CLI
|
||||||
|
|
||||||
|
- **Never decode preset codes or build preset URLs manually.** Use `npx shadcn@latest preset decode <code>`, `preset url <code>`, or `preset open <code>`. For project-aware preset detection, use `npx shadcn@latest preset resolve`.
|
||||||
|
- **Apply preset codes directly with the CLI.** Use `npx shadcn@latest apply <code>` for existing projects, or `npx shadcn@latest init --preset <code>` when initializing.
|
||||||
|
|
||||||
|
## Key Patterns
|
||||||
|
|
||||||
|
These are the most common patterns that differentiate correct shadcn/ui code. For edge cases, see the linked rule files above.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Form layout: FieldGroup + Field, not div + Label.
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||||
|
<Input id="email" />
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
// Validation: data-invalid on Field, aria-invalid on the control.
|
||||||
|
<Field data-invalid>
|
||||||
|
<FieldLabel>Email</FieldLabel>
|
||||||
|
<Input aria-invalid />
|
||||||
|
<FieldDescription>Invalid email.</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
// Icons in buttons: data-icon, no sizing classes.
|
||||||
|
<Button>
|
||||||
|
<SearchIcon data-icon="inline-start" />
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
// Spacing: gap-*, not space-y-*.
|
||||||
|
<div className="flex flex-col gap-4"> // correct
|
||||||
|
<div className="space-y-4"> // wrong
|
||||||
|
|
||||||
|
// Equal dimensions: size-*, not w-* h-*.
|
||||||
|
<Avatar className="size-10"> // correct
|
||||||
|
<Avatar className="w-10 h-10"> // wrong
|
||||||
|
|
||||||
|
// Status colors: Badge variants or semantic tokens, not raw colors.
|
||||||
|
<Badge variant="secondary">+20.1%</Badge> // correct
|
||||||
|
<span className="text-emerald-600">+20.1%</span> // wrong
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Selection
|
||||||
|
|
||||||
|
| Need | Use |
|
||||||
|
| -------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||||
|
| Button/action | `Button` with appropriate variant |
|
||||||
|
| Form inputs | `Input`, `Select`, `Combobox`, `Switch`, `Checkbox`, `RadioGroup`, `Textarea`, `InputOTP`, `Slider` |
|
||||||
|
| Toggle between 2–5 options | `ToggleGroup` + `ToggleGroupItem` |
|
||||||
|
| Data display | `Table`, `Card`, `Badge`, `Avatar` |
|
||||||
|
| Navigation | `Sidebar`, `NavigationMenu`, `Breadcrumb`, `Tabs`, `Pagination` |
|
||||||
|
| Overlays | `Dialog` (modal), `Sheet` (side panel), `Drawer` (bottom sheet), `AlertDialog` (confirmation) |
|
||||||
|
| Feedback | `toast` (Base UI), `sonner` (Radix/Aria), `Alert`, `Progress`, `Skeleton`, `Spinner` |
|
||||||
|
| Command palette | `Command` inside `Dialog` |
|
||||||
|
| Charts | `Chart` (wraps Recharts) |
|
||||||
|
| Layout | `Card`, `Separator`, `Resizable`, `ScrollArea`, `Accordion`, `Collapsible` |
|
||||||
|
| Empty states | `Empty` |
|
||||||
|
| Menus | `DropdownMenu`, `ContextMenu`, `Menubar` |
|
||||||
|
| Tooltips/info | `Tooltip`, `HoverCard`, `Popover` |
|
||||||
|
| Chat / conversation UI | `MessageScroller`, `Message`, `Bubble`, `Attachment`, `Marker` |
|
||||||
|
|
||||||
|
## Key Fields
|
||||||
|
|
||||||
|
The injected project context contains these key fields:
|
||||||
|
|
||||||
|
- **`aliases`** → use the actual alias prefix for imports (e.g. `@/`, `~/`), never hardcode.
|
||||||
|
- **`isRSC`** → when `true`, components using `useState`, `useEffect`, event handlers, or browser APIs need `"use client"` at the top of the file. Always reference this field when advising on the directive.
|
||||||
|
- **`tailwindVersion`** → `"v4"` uses `@theme inline` blocks; `"v3"` uses `tailwind.config.js`.
|
||||||
|
- **`tailwindCssFile`** → the global CSS file where custom CSS variables are defined. Always edit this file, never create a new one.
|
||||||
|
- **`style`** → component visual treatment (e.g. `nova`, `vega`).
|
||||||
|
- **`base`** → primitive library (`radix` or `base`). Affects component APIs and available props.
|
||||||
|
- **`iconLibrary`** → determines icon imports. Use `lucide-react` for `lucide`, `@tabler/icons-react` for `tabler`, etc. Never assume `lucide-react`.
|
||||||
|
- **`resolvedPaths`** → exact file-system destinations for components, utils, hooks, etc.
|
||||||
|
- **`framework`** → routing and file conventions (e.g. Next.js App Router vs Vite SPA).
|
||||||
|
- **`packageManager`** → use this for any non-shadcn dependency installs (e.g. `pnpm add date-fns` vs `npm install date-fns`).
|
||||||
|
- **`preset`** → resolved preset code and values for the current project. Use `npx shadcn@latest preset resolve --json` when you only need preset information.
|
||||||
|
|
||||||
|
See [cli.md — `info` command](./cli.md) for the full field reference.
|
||||||
|
|
||||||
|
## Component Docs, Examples, and Usage
|
||||||
|
|
||||||
|
Run `npx shadcn@latest docs <component>` to get the URLs for a component's documentation, examples, and API reference. Fetch these URLs to get the actual content.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest docs button dialog select
|
||||||
|
```
|
||||||
|
|
||||||
|
**When creating, fixing, debugging, or using a component, always run `npx shadcn@latest docs` and fetch the URLs first.** This ensures you're working with the correct API and usage patterns rather than guessing.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. **Get project context** — already injected above. Run `npx shadcn@latest info` again if you need to refresh.
|
||||||
|
2. **Check installed components first** — before running `add`, always check the `components` list from project context or list the `resolvedPaths.ui` directory. Don't import components that haven't been added, and don't re-add ones already installed.
|
||||||
|
3. **Find components** — `npx shadcn@latest search`.
|
||||||
|
4. **Get docs and examples** — run `npx shadcn@latest docs <component>` to get URLs, then fetch them. Use `npx shadcn@latest view` to browse registry items you haven't installed. To preview changes to installed components, use `npx shadcn@latest add --diff`.
|
||||||
|
5. **Install or update** — `npx shadcn@latest add`. When updating existing components, use `--dry-run` and `--diff` to preview changes first (see [Updating Components](#updating-components) below).
|
||||||
|
6. **Fix imports in third-party components** — After adding components from community registries (e.g. `@bundui`, `@magicui`), check the added non-UI files for hardcoded import paths like `@/components/ui/...`. These won't match the project's actual aliases. Use `npx shadcn@latest info` to get the correct `ui` alias (e.g. `@workspace/ui/components`) and rewrite the imports accordingly. The CLI rewrites imports for its own UI files, but third-party registry components may use default paths that don't match the project.
|
||||||
|
7. **Review added components** — After adding a component or block from any registry, **always read the added files and verify they are correct**. Check for missing sub-components (e.g. `SelectItem` without `SelectGroup`), missing imports, incorrect composition, or violations of the [Critical Rules](#critical-rules). Also replace any icon imports with the project's `iconLibrary` from the project context (e.g. if the registry item uses `lucide-react` but the project uses `hugeicons`, swap the imports and icon names accordingly). Fix all issues before moving on.
|
||||||
|
8. **Registry must be explicit** — When the user asks to add a block or component, **do not guess the registry**. If no registry is specified (e.g. user says "add a login block" without specifying `@shadcn`, `@tailark`, `owner/repo`, etc.), ask which registry to use. Never default to a registry on behalf of the user.
|
||||||
|
9. **Switching presets** — Ask the user first: **overwrite**, **partial**, **merge**, or **skip**?
|
||||||
|
- **Inspect current preset**: `npx shadcn@latest preset resolve`. Use `--json` when you need structured values.
|
||||||
|
- **Inspect incoming preset**: `npx shadcn@latest preset decode <code>`. Use `preset url <code>` or `preset open <code>` to share or open the preset builder.
|
||||||
|
- **Overwrite**: `npx shadcn@latest apply <code>`. Overwrites detected components, fonts, and CSS variables.
|
||||||
|
- **Partial**: `npx shadcn@latest apply <code> --only theme,font`. Updates only the selected preset parts without reinstalling UI components. Supported values are `theme` and `font`; comma-separated combinations are allowed. `icon` is intentionally not supported, because icon changes may require full component reinstall and transforms.
|
||||||
|
- **Merge**: `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to list installed components, then for each installed component use `--dry-run` and `--diff` to [smart merge](#updating-components) it individually.
|
||||||
|
- **Skip**: `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS, leaves components as-is.
|
||||||
|
- **Important**: Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
|
||||||
|
|
||||||
|
## Updating Components
|
||||||
|
|
||||||
|
When the user asks to update a component from upstream while keeping their local changes, use `--dry-run` and `--diff` to intelligently merge. **NEVER fetch raw files from GitHub manually — always use the CLI.**
|
||||||
|
|
||||||
|
1. Run `npx shadcn@latest add <component> --dry-run` to see all files that would be affected.
|
||||||
|
2. For each file, run `npx shadcn@latest add <component> --diff <file>` to see what changed upstream vs local.
|
||||||
|
3. Decide per file based on the diff:
|
||||||
|
- No local changes → safe to overwrite.
|
||||||
|
- Has local changes → read the local file, analyze the diff, and apply upstream updates while preserving local modifications.
|
||||||
|
- User says "just update everything" → use `--overwrite`, but confirm first.
|
||||||
|
4. **Never use `--overwrite` without the user's explicit approval.**
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a new project.
|
||||||
|
npx shadcn@latest init --name my-app --preset base-nova
|
||||||
|
npx shadcn@latest init --name my-app --preset a2r6bw --template vite
|
||||||
|
|
||||||
|
# Create a monorepo project.
|
||||||
|
npx shadcn@latest init --name my-app --preset base-nova --monorepo
|
||||||
|
npx shadcn@latest init --name my-app --preset base-nova --template next --monorepo
|
||||||
|
|
||||||
|
# Initialize existing project.
|
||||||
|
npx shadcn@latest init --preset base-nova
|
||||||
|
npx shadcn@latest init --defaults # shortcut: --template=next --preset=nova (base style implied)
|
||||||
|
|
||||||
|
# Apply a preset to an existing project.
|
||||||
|
npx shadcn@latest apply a2r6bw
|
||||||
|
npx shadcn@latest apply a2r6bw --only theme
|
||||||
|
npx shadcn@latest apply a2r6bw --only font
|
||||||
|
npx shadcn@latest apply a2r6bw --only theme,font
|
||||||
|
|
||||||
|
# Inspect preset codes and project preset state.
|
||||||
|
npx shadcn@latest preset decode a2r6bw
|
||||||
|
npx shadcn@latest preset url a2r6bw
|
||||||
|
npx shadcn@latest preset open a2r6bw
|
||||||
|
npx shadcn@latest preset resolve
|
||||||
|
npx shadcn@latest preset resolve --json
|
||||||
|
|
||||||
|
# Add components.
|
||||||
|
npx shadcn@latest add button card dialog
|
||||||
|
npx shadcn@latest add @magicui/shimmer-button
|
||||||
|
npx shadcn@latest add owner/repo/item
|
||||||
|
npx shadcn@latest add --all
|
||||||
|
|
||||||
|
# Preview changes before adding/updating.
|
||||||
|
npx shadcn@latest add button --dry-run
|
||||||
|
npx shadcn@latest add button --diff button.tsx
|
||||||
|
npx shadcn@latest add @acme/form --view button.tsx
|
||||||
|
npx shadcn@latest add owner/repo/item --dry-run
|
||||||
|
|
||||||
|
# Search registries.
|
||||||
|
npx shadcn@latest search @shadcn -q "sidebar"
|
||||||
|
npx shadcn@latest search @tailark -q "stats"
|
||||||
|
npx shadcn@latest search owner/repo -q "login"
|
||||||
|
npx shadcn@latest search # all configured registries
|
||||||
|
npx shadcn@latest search @shadcn -q "menu" -t ui # filter by item type
|
||||||
|
|
||||||
|
# Get component docs and example URLs.
|
||||||
|
npx shadcn@latest docs button dialog select
|
||||||
|
|
||||||
|
# View registry item details (for items not yet installed).
|
||||||
|
npx shadcn@latest view @shadcn/button
|
||||||
|
npx shadcn@latest view owner/repo/item
|
||||||
|
```
|
||||||
|
|
||||||
|
**Named presets:** `nova`, `vega`, `maia`, `lyra`, `mira`, `luma`
|
||||||
|
**Templates:** `next`, `vite`, `start`, `react-router`, `astro` (all support `--monorepo`) and `laravel` (not supported for monorepo)
|
||||||
|
**Preset codes:** Version-prefixed base62 strings (e.g. `a2r6bw` or `b0`), from [ui.shadcn.com](https://ui.shadcn.com).
|
||||||
|
|
||||||
|
## Detailed References
|
||||||
|
|
||||||
|
- [rules/forms.md](./rules/forms.md) — FieldGroup, Field, InputGroup, ToggleGroup, FieldSet, validation states
|
||||||
|
- [rules/composition.md](./rules/composition.md) — Groups, overlays, Card, Tabs, Avatar, Alert, Empty, Toast, Separator, Skeleton, Badge, Button loading
|
||||||
|
- [rules/chat.md](./rules/chat.md) — MessageScroller, Message, Bubble, Attachment, Marker; streaming, anchoring, jump-to-latest
|
||||||
|
- [rules/icons.md](./rules/icons.md) — data-icon, icon sizing, passing icons as objects
|
||||||
|
- [rules/styling.md](./rules/styling.md) — Semantic colors, variants, className, spacing, size, truncate, dark mode, cn(), z-index
|
||||||
|
- [rules/base-vs-radix.md](./rules/base-vs-radix.md) — asChild vs render, Select, ToggleGroup, Slider, Accordion
|
||||||
|
- [cli.md](./cli.md) — Commands, flags, presets, templates
|
||||||
|
- [registry.md](./registry.md) — Authoring source registries, `include`, item definitions, dependencies, GitHub registry rules
|
||||||
|
- [customization.md](./customization.md) — Theming, CSS variables, extending components
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "shadcn/ui"
|
||||||
|
short_description: "Manages shadcn/ui components — adding, searching, fixing, debugging, styling, and composing UI."
|
||||||
|
icon_small: "./assets/shadcn-small.png"
|
||||||
|
icon_large: "./assets/shadcn.png"
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,290 @@
|
|||||||
|
# shadcn CLI Reference
|
||||||
|
|
||||||
|
Configuration is read from `components.json`.
|
||||||
|
|
||||||
|
> **IMPORTANT:** Always run commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest`. Check `packageManager` from project context to choose the right one. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
|
||||||
|
|
||||||
|
> **IMPORTANT:** Only use the flags documented below. Do not invent or guess flags — if a flag isn't listed here, it doesn't exist. The CLI auto-detects the package manager from the project's lockfile; there is no `--package-manager` flag.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- Commands: init, apply, add (dry-run, smart merge), search, view, docs, info, build
|
||||||
|
- Templates: next, vite, start, react-router, astro
|
||||||
|
- Presets: named, code, URL formats and fields
|
||||||
|
- Switching presets
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### `init` — Initialize or create a project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest init [components...] [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Initializes shadcn/ui in an existing project or creates a new project (when `--name` is provided). Optionally installs components in the same step.
|
||||||
|
|
||||||
|
| Flag | Short | Description | Default |
|
||||||
|
| ----------------------- | ----- | --------------------------------------------------------- | ------- |
|
||||||
|
| `--template <template>` | `-t` | Template (next, start, vite, next-monorepo, react-router) | — |
|
||||||
|
| `--preset [name]` | `-p` | Preset configuration (named, code, or URL) | — |
|
||||||
|
| `--yes` | `-y` | Skip confirmation prompt | `true` |
|
||||||
|
| `--defaults` | `-d` | Use defaults (`--template=next --preset=base-nova`) | `false` |
|
||||||
|
| `--force` | `-f` | Force overwrite existing configuration | `false` |
|
||||||
|
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||||
|
| `--name <name>` | `-n` | Name for new project | — |
|
||||||
|
| `--silent` | `-s` | Mute output | `false` |
|
||||||
|
| `--rtl` | | Enable RTL support | — |
|
||||||
|
| `--reinstall` | | Re-install existing UI components | `false` |
|
||||||
|
| `--monorepo` | | Scaffold a monorepo project | — |
|
||||||
|
| `--no-monorepo` | | Skip the monorepo prompt | — |
|
||||||
|
|
||||||
|
`npx shadcn@latest create` is an alias for `npx shadcn@latest init`.
|
||||||
|
|
||||||
|
### `apply` — Apply a preset to an existing project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest apply [preset] [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Applies a preset to an existing project, overwriting preset-driven config, fonts, CSS variables, and detected UI components.
|
||||||
|
|
||||||
|
| Flag | Short | Description | Default |
|
||||||
|
| ------------------- | ----- | ------------------------------------------ | ------- |
|
||||||
|
| `--preset <preset>` | — | Preset configuration (named, code, or URL) | — |
|
||||||
|
| `--yes` | `-y` | Skip confirmation prompt | `false` |
|
||||||
|
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||||
|
| `--silent` | `-s` | Mute output | `false` |
|
||||||
|
|
||||||
|
`[preset]` is a shorthand for `--preset <preset>`. If both are provided, they must match.
|
||||||
|
If no preset is provided, the CLI offers to open the custom preset builder on `ui.shadcn.com/create`.
|
||||||
|
|
||||||
|
### `add` — Add components
|
||||||
|
|
||||||
|
> **IMPORTANT:** To compare local components against upstream or to preview changes, ALWAYS use `npx shadcn@latest add <component> --dry-run`, `--diff`, or `--view`. NEVER fetch raw files from GitHub or other sources manually. The CLI handles registry resolution, file paths, and CSS diffing automatically.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add [components...] [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Accepts component names, registry-prefixed names (`@magicui/shimmer-button`),
|
||||||
|
GitHub item addresses (`owner/repo/item`), URLs, or local paths.
|
||||||
|
|
||||||
|
| Flag | Short | Description | Default |
|
||||||
|
| --------------- | ----- | -------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||||
|
| `--yes` | `-y` | Skip confirmation prompt | `false` |
|
||||||
|
| `--overwrite` | `-o` | Overwrite existing files | `false` |
|
||||||
|
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||||
|
| `--all` | `-a` | Add all available components | `false` |
|
||||||
|
| `--path <path>` | `-p` | Target path for the component | — |
|
||||||
|
| `--silent` | `-s` | Mute output | `false` |
|
||||||
|
| `--dry-run` | | Preview all changes without writing files | `false` |
|
||||||
|
| `--diff [path]` | | Show diffs. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | — |
|
||||||
|
| `--view [path]` | | Show file contents. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | — |
|
||||||
|
|
||||||
|
#### Dry-Run Mode
|
||||||
|
|
||||||
|
Use `--dry-run` to preview what `add` would do without writing any files. `--diff` and `--view` both imply `--dry-run`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Preview all changes.
|
||||||
|
npx shadcn@latest add button --dry-run
|
||||||
|
|
||||||
|
# Show diffs for all files (top 5).
|
||||||
|
npx shadcn@latest add button --diff
|
||||||
|
|
||||||
|
# Show the diff for a specific file.
|
||||||
|
npx shadcn@latest add button --diff button.tsx
|
||||||
|
|
||||||
|
# Show contents for all files (top 5).
|
||||||
|
npx shadcn@latest add button --view
|
||||||
|
|
||||||
|
# Show the full content of a specific file.
|
||||||
|
npx shadcn@latest add button --view button.tsx
|
||||||
|
|
||||||
|
# Works with URLs too.
|
||||||
|
npx shadcn@latest add https://api.npoint.io/abc123 --dry-run
|
||||||
|
|
||||||
|
# Works with public GitHub registries too.
|
||||||
|
npx shadcn@latest add owner/repo/item --dry-run
|
||||||
|
|
||||||
|
# CSS diffs.
|
||||||
|
npx shadcn@latest add button --diff globals.css
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use dry-run:**
|
||||||
|
|
||||||
|
- When the user asks "what files will this add?" or "what will this change?" — use `--dry-run`.
|
||||||
|
- Before overwriting existing components — use `--diff` to preview the changes first.
|
||||||
|
- When the user wants to inspect component source code without installing — use `--view`.
|
||||||
|
- When checking what CSS changes would be made to `globals.css` — use `--diff globals.css`.
|
||||||
|
- When the user asks to review or audit third-party registry code before installing — use `--view` to inspect the source.
|
||||||
|
|
||||||
|
> **`npx shadcn@latest add --dry-run` vs `npx shadcn@latest view`:** Prefer `npx shadcn@latest add --dry-run/--diff/--view` over `npx shadcn@latest view` when the user wants to preview changes to their project. `npx shadcn@latest view` only shows raw registry metadata. `npx shadcn@latest add --dry-run` shows exactly what would happen in the user's project: resolved file paths, diffs against existing files, and CSS updates. Use `npx shadcn@latest view` only when the user wants to browse registry info without a project context.
|
||||||
|
|
||||||
|
#### Smart Merge from Upstream
|
||||||
|
|
||||||
|
See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full workflow.
|
||||||
|
|
||||||
|
### `search` — Search registries
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest search [registries...] [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Fuzzy search across registries. Also aliased as `npx shadcn@latest list`.
|
||||||
|
Supports namespaces (`@acme`), public GitHub registry sources (`owner/repo`),
|
||||||
|
and registry catalog URLs. Without `-q`, lists all items. When no registries are
|
||||||
|
passed, searches every registry configured in `components.json`.
|
||||||
|
|
||||||
|
| Flag | Short | Description | Default |
|
||||||
|
| ------------------- | ----- | ------------------------------------------------- | ------- |
|
||||||
|
| `--query <query>` | `-q` | Search query | — |
|
||||||
|
| `--type <type>` | `-t` | Filter by item type (e.g. `ui`, `block`, `hook`); comma-separated | — |
|
||||||
|
| `--limit <number>` | `-l` | Max items to display | `100` |
|
||||||
|
| `--offset <number>` | `-o` | Items to skip | `0` |
|
||||||
|
| `--json` | | Output as JSON | `false` |
|
||||||
|
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||||
|
|
||||||
|
### `view` — View item details
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest view <items...> [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Displays item info including file contents. Examples:
|
||||||
|
`npx shadcn@latest view @shadcn/button`,
|
||||||
|
`npx shadcn@latest view owner/repo/item`.
|
||||||
|
|
||||||
|
### `docs` — Get component documentation URLs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest docs <components...> [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Outputs resolved URLs for component documentation, examples, and API references. Accepts one or more component names. Fetch the URLs to get the actual content.
|
||||||
|
|
||||||
|
Example output for `npx shadcn@latest docs input button`:
|
||||||
|
|
||||||
|
```
|
||||||
|
base radix
|
||||||
|
|
||||||
|
input
|
||||||
|
docs https://ui.shadcn.com/docs/components/radix/input
|
||||||
|
examples https://raw.githubusercontent.com/.../examples/input-example.tsx
|
||||||
|
|
||||||
|
button
|
||||||
|
docs https://ui.shadcn.com/docs/components/radix/button
|
||||||
|
examples https://raw.githubusercontent.com/.../examples/button-example.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Some components include an `api` link to the underlying library (e.g. `cmdk` for the command component).
|
||||||
|
|
||||||
|
### `diff` — Check for updates
|
||||||
|
|
||||||
|
Do not use this command. Use `npx shadcn@latest add --diff` instead.
|
||||||
|
|
||||||
|
### `info` — Project information
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest info [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Displays project info and `components.json` configuration. Run this first to discover the project's framework, aliases, Tailwind version, and resolved paths.
|
||||||
|
|
||||||
|
| Flag | Short | Description | Default |
|
||||||
|
| ------------- | ----- | ----------------- | ------- |
|
||||||
|
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||||
|
|
||||||
|
**Project Info fields:**
|
||||||
|
|
||||||
|
| Field | Type | Meaning |
|
||||||
|
| -------------------- | --------- | ------------------------------------------------------------------ |
|
||||||
|
| `framework` | `string` | Detected framework (`next`, `vite`, `react-router`, `start`, etc.) |
|
||||||
|
| `frameworkVersion` | `string` | Framework version (e.g. `15.2.4`) |
|
||||||
|
| `isSrcDir` | `boolean` | Whether the project uses a `src/` directory |
|
||||||
|
| `isRSC` | `boolean` | Whether React Server Components are enabled |
|
||||||
|
| `isTsx` | `boolean` | Whether the project uses TypeScript |
|
||||||
|
| `tailwindVersion` | `string` | `"v3"` or `"v4"` |
|
||||||
|
| `tailwindConfigFile` | `string` | Path to the Tailwind config file |
|
||||||
|
| `tailwindCssFile` | `string` | Path to the global CSS file |
|
||||||
|
| `aliasPrefix` | `string` | Import alias prefix (e.g. `@`, `~`, `@/`) |
|
||||||
|
| `packageManager` | `string` | Detected package manager (`npm`, `pnpm`, `yarn`, `bun`) |
|
||||||
|
|
||||||
|
**Components.json fields:**
|
||||||
|
|
||||||
|
| Field | Type | Meaning |
|
||||||
|
| -------------------- | --------- | ------------------------------------------------------------------------------------------ |
|
||||||
|
| `base` | `string` | Primitive library (`radix` or `base`) — determines component APIs and available props |
|
||||||
|
| `style` | `string` | Visual style (e.g. `nova`, `vega`) |
|
||||||
|
| `rsc` | `boolean` | RSC flag from config |
|
||||||
|
| `tsx` | `boolean` | TypeScript flag |
|
||||||
|
| `tailwind.config` | `string` | Tailwind config path |
|
||||||
|
| `tailwind.css` | `string` | Global CSS path — this is where custom CSS variables go |
|
||||||
|
| `iconLibrary` | `string` | Icon library — determines icon import package (e.g. `lucide-react`, `@tabler/icons-react`) |
|
||||||
|
| `aliases.components` | `string` | Component import alias (e.g. `@/components`) |
|
||||||
|
| `aliases.utils` | `string` | Utils import alias (e.g. `@/lib/utils`) |
|
||||||
|
| `aliases.ui` | `string` | UI component alias (e.g. `@/components/ui`) |
|
||||||
|
| `aliases.lib` | `string` | Lib alias (e.g. `@/lib`) |
|
||||||
|
| `aliases.hooks` | `string` | Hooks alias (e.g. `@/hooks`) |
|
||||||
|
| `resolvedPaths` | `object` | Absolute file-system paths for each alias |
|
||||||
|
| `registries` | `object` | Configured custom registries |
|
||||||
|
|
||||||
|
**Links fields:**
|
||||||
|
|
||||||
|
The `info` output includes a **Links** section with templated URLs for component docs, source, and examples. For resolved URLs, use `npx shadcn@latest docs <component>` instead.
|
||||||
|
|
||||||
|
### `build` — Build a custom registry
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest build [registry] [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
Builds `registry.json` into individual JSON files for distribution. Default input: `./registry.json`, default output: `./public/r`.
|
||||||
|
|
||||||
|
For authoring rules, `include`, item definitions, `registryDependencies`, and
|
||||||
|
GitHub registry behavior, see [registry.md](./registry.md).
|
||||||
|
|
||||||
|
| Flag | Short | Description | Default |
|
||||||
|
| ----------------- | ----- | ----------------- | ------------ |
|
||||||
|
| `--output <path>` | `-o` | Output directory | `./public/r` |
|
||||||
|
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Templates
|
||||||
|
|
||||||
|
| Value | Framework | Monorepo support |
|
||||||
|
| -------------- | -------------- | ---------------- |
|
||||||
|
| `next` | Next.js | Yes |
|
||||||
|
| `vite` | Vite | Yes |
|
||||||
|
| `start` | TanStack Start | Yes |
|
||||||
|
| `react-router` | React Router | Yes |
|
||||||
|
| `astro` | Astro | Yes |
|
||||||
|
| `laravel` | Laravel | No |
|
||||||
|
|
||||||
|
All templates support monorepo scaffolding via the `--monorepo` flag. When passed, the CLI uses a monorepo-specific template directory (e.g. `next-monorepo`, `vite-monorepo`). When neither `--monorepo` nor `--no-monorepo` is passed, the CLI prompts interactively. Laravel does not support monorepo scaffolding.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Presets
|
||||||
|
|
||||||
|
Three ways to specify a preset via `--preset`:
|
||||||
|
|
||||||
|
1. **Named:** `--preset nova` or `--preset lyra`
|
||||||
|
2. **Code:** `--preset a2r6bw` (version-prefixed base62 string, e.g. `a2r6bw` or `b0`)
|
||||||
|
3. **URL:** `--preset "https://ui.shadcn.com/init?base=radix&style=nova&..."`
|
||||||
|
|
||||||
|
> **IMPORTANT:** Never try to decode, fetch, or resolve preset codes manually. Preset codes are opaque — pass them directly to `npx shadcn@latest init --preset <code>` and let the CLI handle resolution.
|
||||||
|
> Use `npx shadcn@latest apply --preset <code>` when overwriting an existing project's preset.
|
||||||
|
|
||||||
|
## Switching Presets
|
||||||
|
|
||||||
|
Ask the user first: **overwrite**, **merge**, or **skip** existing components?
|
||||||
|
|
||||||
|
- **Overwrite / Re-install** → `npx shadcn@latest apply --preset <code>`. Overwrites all detected component files with the new preset styles. Use when the user hasn't customized components.
|
||||||
|
- **Merge** → `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to get the list of installed components and use the [smart merge workflow](./SKILL.md#updating-components) to update them one by one, preserving local changes. Use when the user has customized components.
|
||||||
|
- **Skip** → `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS variables, leaves existing components as-is.
|
||||||
|
|
||||||
|
Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
# Customization & Theming
|
||||||
|
|
||||||
|
Components reference semantic CSS variable tokens. Change the variables to change every component.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- How it works (CSS variables → Tailwind utilities → components)
|
||||||
|
- Color variables and OKLCH format
|
||||||
|
- Dark mode setup
|
||||||
|
- Changing the theme (presets, CSS variables)
|
||||||
|
- Adding custom colors (Tailwind v3 and v4)
|
||||||
|
- Border radius
|
||||||
|
- Customizing components (variants, className, wrappers)
|
||||||
|
- Checking for updates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. CSS variables defined in `:root` (light) and `.dark` (dark mode).
|
||||||
|
2. Tailwind maps them to utilities: `bg-primary`, `text-muted-foreground`, etc.
|
||||||
|
3. Components use these utilities — changing a variable changes all components that reference it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Color Variables
|
||||||
|
|
||||||
|
Every color follows the `name` / `name-foreground` convention. The base variable is for backgrounds, `-foreground` is for text/icons on that background.
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
| -------------------------------------------- | -------------------------------- |
|
||||||
|
| `--background` / `--foreground` | Page background and default text |
|
||||||
|
| `--card` / `--card-foreground` | Card surfaces |
|
||||||
|
| `--primary` / `--primary-foreground` | Primary buttons and actions |
|
||||||
|
| `--secondary` / `--secondary-foreground` | Secondary actions |
|
||||||
|
| `--muted` / `--muted-foreground` | Muted/disabled states |
|
||||||
|
| `--accent` / `--accent-foreground` | Hover and accent states |
|
||||||
|
| `--destructive` / `--destructive-foreground` | Error and destructive actions |
|
||||||
|
| `--border` | Default border color |
|
||||||
|
| `--input` | Form input borders |
|
||||||
|
| `--ring` | Focus ring color |
|
||||||
|
| `--chart-1` through `--chart-5` | Chart/data visualization |
|
||||||
|
| `--sidebar-*` | Sidebar-specific colors |
|
||||||
|
| `--surface` / `--surface-foreground` | Secondary surface |
|
||||||
|
|
||||||
|
Colors use OKLCH: `--primary: oklch(0.205 0 0)` where values are lightness (0–1), chroma (0 = gray), and hue (0–360).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dark Mode
|
||||||
|
|
||||||
|
Class-based toggle via `.dark` on the root element. In Next.js, use `next-themes`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ThemeProvider } from "next-themes"
|
||||||
|
|
||||||
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
|
{children}
|
||||||
|
</ThemeProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changing the Theme
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Apply a preset code from ui.shadcn.com.
|
||||||
|
npx shadcn@latest apply --preset a2r6bw
|
||||||
|
|
||||||
|
# Positional shorthand also works.
|
||||||
|
npx shadcn@latest apply a2r6bw
|
||||||
|
|
||||||
|
# Switch to a named preset and overwrite existing components.
|
||||||
|
npx shadcn@latest apply --preset nova
|
||||||
|
|
||||||
|
# Preserve existing components instead.
|
||||||
|
npx shadcn@latest init --preset nova --force --no-reinstall
|
||||||
|
|
||||||
|
# Use a custom theme URL.
|
||||||
|
npx shadcn@latest apply --preset "https://ui.shadcn.com/init?base=radix&style=nova&theme=blue&..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Or edit CSS variables directly in `globals.css`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding Custom Colors
|
||||||
|
|
||||||
|
Add variables to the file at `tailwindCssFile` from `npx shadcn@latest info` (typically `globals.css`). Never create a new CSS file for this.
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 1. Define in the global CSS file. */
|
||||||
|
:root {
|
||||||
|
--warning: oklch(0.84 0.16 84);
|
||||||
|
--warning-foreground: oklch(0.28 0.07 46);
|
||||||
|
}
|
||||||
|
.dark {
|
||||||
|
--warning: oklch(0.41 0.11 46);
|
||||||
|
--warning-foreground: oklch(0.99 0.02 95);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 2a. Register with Tailwind v4 (@theme inline). */
|
||||||
|
@theme inline {
|
||||||
|
--color-warning: var(--warning);
|
||||||
|
--color-warning-foreground: var(--warning-foreground);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When `tailwindVersion` is `"v3"` (check via `npx shadcn@latest info`), register in `tailwind.config.js` instead:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 2b. Register with Tailwind v3 (tailwind.config.js).
|
||||||
|
module.exports = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
warning: "oklch(var(--warning) / <alpha-value>)",
|
||||||
|
"warning-foreground":
|
||||||
|
"oklch(var(--warning-foreground) / <alpha-value>)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 3. Use in components.
|
||||||
|
<div className="bg-warning text-warning-foreground">Warning</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Border Radius
|
||||||
|
|
||||||
|
`--radius` controls border radius globally. Components derive values from it (`rounded-lg` = `var(--radius)`, `rounded-md` = `calc(var(--radius) - 2px)`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Customizing Components
|
||||||
|
|
||||||
|
See also: [rules/styling.md](./rules/styling.md) for Incorrect/Correct examples.
|
||||||
|
|
||||||
|
Prefer these approaches in order:
|
||||||
|
|
||||||
|
### 1. Built-in variants
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Click
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Tailwind classes via `className`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Card className="mx-auto max-w-md">...</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Add a new variant
|
||||||
|
|
||||||
|
Edit the component source to add a variant via `cva`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/ui/button.tsx
|
||||||
|
warning: "bg-warning text-warning-foreground hover:bg-warning/90",
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Wrapper components
|
||||||
|
|
||||||
|
Compose shadcn/ui primitives into higher-level components:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export function ConfirmDialog({ title, description, onConfirm, children }) {
|
||||||
|
return (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={onConfirm}>Confirm</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checking for Updates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add button --diff
|
||||||
|
```
|
||||||
|
|
||||||
|
To preview exactly what would change before updating, use `--dry-run` and `--diff`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest add button --dry-run # see all affected files
|
||||||
|
npx shadcn@latest add button --diff button.tsx # see the diff for a specific file
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full smart merge workflow.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
{
|
||||||
|
"skill_name": "shadcn",
|
||||||
|
"evals": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"prompt": "I'm building a Next.js app with shadcn/ui (base-nova preset, lucide icons). Create a settings form component with fields for: full name, email address, and notification preferences (email, SMS, push notifications as toggle options). Add validation states for required fields.",
|
||||||
|
"expected_output": "A React component using FieldGroup, Field, ToggleGroup, data-invalid/aria-invalid validation, gap-* spacing, and semantic colors.",
|
||||||
|
"files": [],
|
||||||
|
"expectations": [
|
||||||
|
"Uses FieldGroup and Field components for form layout instead of raw div with space-y",
|
||||||
|
"Uses Switch for independent on/off notification toggles (not looping Button with manual active state)",
|
||||||
|
"Uses data-invalid on Field and aria-invalid on the input control for validation states",
|
||||||
|
"Uses gap-* (e.g. gap-4, gap-6) instead of space-y-* or space-x-* for spacing",
|
||||||
|
"Uses semantic color tokens (e.g. bg-background, text-muted-foreground, text-destructive) instead of raw colors like bg-red-500",
|
||||||
|
"No manual dark: color overrides"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"prompt": "Create a dialog component for editing a user profile. It should have the user's avatar at the top, input fields for name and bio, and Save/Cancel buttons with appropriate icons. Using shadcn/ui with radix-nova preset and tabler icons.",
|
||||||
|
"expected_output": "A React component with DialogTitle, Avatar+AvatarFallback, data-icon on icon buttons, no icon sizing classes, tabler icon imports.",
|
||||||
|
"files": [],
|
||||||
|
"expectations": [
|
||||||
|
"Includes DialogTitle for accessibility (visible or with sr-only class)",
|
||||||
|
"Avatar component includes AvatarFallback",
|
||||||
|
"Icons on buttons use the data-icon attribute (data-icon=\"inline-start\" or data-icon=\"inline-end\")",
|
||||||
|
"No sizing classes on icons inside components (no size-4, w-4, h-4, etc.)",
|
||||||
|
"Uses tabler icons (@tabler/icons-react) instead of lucide-react",
|
||||||
|
"Uses asChild for custom triggers (radix preset)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"prompt": "Create a dashboard component that shows 4 stat cards in a grid. Each card has a title, large number, percentage change badge, and a loading skeleton state. Using shadcn/ui with base-nova preset and lucide icons.",
|
||||||
|
"expected_output": "A React component with full Card composition, Skeleton for loading, Badge for changes, semantic colors, gap-* spacing.",
|
||||||
|
"files": [],
|
||||||
|
"expectations": [
|
||||||
|
"Uses full Card composition with CardHeader, CardTitle, CardContent (not dumping everything into CardContent)",
|
||||||
|
"Uses Skeleton component for loading placeholders instead of custom animate-pulse divs",
|
||||||
|
"Uses Badge component for percentage change instead of custom styled spans",
|
||||||
|
"Uses semantic color tokens instead of raw color values like bg-green-500 or text-red-600",
|
||||||
|
"Uses gap-* instead of space-y-* or space-x-* for spacing",
|
||||||
|
"Uses size-* when width and height are equal instead of separate w-* h-*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"prompt": "I'm building a Next.js app with shadcn/ui (base-nova preset, lucide icons). Build a chat conversation view: a scrollable thread of messages from two different people, each with an avatar, sender name, timestamp, and message bubble. A couple of messages include an image attachment and a PDF file attachment, and there's a 'Today' divider separating the days.",
|
||||||
|
"expected_output": "A React component composing MessageScroller, Message, Bubble, Attachment, and Marker from the registry instead of hand-rolled bubble/divider/attachment markup.",
|
||||||
|
"files": [],
|
||||||
|
"expectations": [
|
||||||
|
"Uses MessageScroller (MessageScrollerProvider, MessageScrollerViewport, MessageScrollerContent, MessageScrollerItem) for the scrollable thread instead of a raw overflow-y-auto div or ScrollArea",
|
||||||
|
"Wraps each row in MessageScrollerItem inside MessageScrollerContent",
|
||||||
|
"Uses Message with MessageAvatar/MessageContent/MessageHeader for row layout instead of custom flex divs",
|
||||||
|
"Uses Bubble + BubbleContent for the message surface instead of a styled div with bg-muted/bg-primary",
|
||||||
|
"Uses Attachment (AttachmentMedia, AttachmentContent, AttachmentTitle, AttachmentDescription) for the file and image attachments instead of Item or a custom card",
|
||||||
|
"Uses Marker (variant=\"separator\") for the 'Today' divider instead of Separator plus a centered label",
|
||||||
|
"Uses semantic color tokens and gap-* spacing; no raw colors like bg-emerald-500 and no space-y-*",
|
||||||
|
"Includes \"use client\" when the component uses state or event handlers (isRSC)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"prompt": "Using shadcn/ui (base-nova preset, lucide icons), build a streaming AI chat UI. The assistant's reply streams in while it generates, the view auto-scrolls to follow the latest content but stops following if the user scrolls up to read earlier messages, a 'jump to latest' button appears when the user has scrolled away from the bottom, and a subtle 'thinking…' shimmer shows while the model is generating.",
|
||||||
|
"expected_output": "A React component that delegates scroll/anchor behavior to MessageScroller and uses MessageScrollerButton for jump-to-latest and the shimmer utility for the thinking indicator — no hand-rolled scroll logic or custom shimmer keyframes.",
|
||||||
|
"files": [],
|
||||||
|
"expectations": [
|
||||||
|
"Uses MessageScroller with MessageScrollerProvider (autoScroll) and scrollAnchor on message items for the stick-to-bottom/follow behavior instead of a custom useStickToBottom hook or ResizeObserver/scrollTop wiring",
|
||||||
|
"Uses MessageScrollerButton for the jump-to-latest control instead of a hand-built conditional button driven by manual scroll-position state",
|
||||||
|
"Uses the shimmer utility class for the 'thinking…' indicator instead of a custom @keyframes or bg-clip-text gradient animation",
|
||||||
|
"Wraps each message row in MessageScrollerItem inside MessageScrollerContent",
|
||||||
|
"Uses Message + Bubble + BubbleContent for the conversation rows instead of hand-rolled bubble divs",
|
||||||
|
"Uses semantic color tokens and gap-* spacing; includes \"use client\" (isRSC)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# shadcn MCP Server
|
||||||
|
|
||||||
|
The CLI includes an MCP server that lets AI assistants search, browse, view, and install items from registries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
shadcn mcp # start the MCP server (stdio)
|
||||||
|
shadcn mcp init # write config for your editor
|
||||||
|
```
|
||||||
|
|
||||||
|
Editor config files:
|
||||||
|
|
||||||
|
| Editor | Config file |
|
||||||
|
| ----------- | ------------------------------- |
|
||||||
|
| Claude Code | `.mcp.json` |
|
||||||
|
| Cursor | `.cursor/mcp.json` |
|
||||||
|
| VS Code | `.vscode/mcp.json` |
|
||||||
|
| OpenCode | `opencode.json` |
|
||||||
|
| Codex | `~/.codex/config.toml` (manual) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
> **Tip:** MCP tools handle registry operations (search, view, install). For project configuration (aliases, framework, Tailwind version), use `npx shadcn@latest info` — there is no MCP equivalent.
|
||||||
|
|
||||||
|
### `shadcn:get_project_registries`
|
||||||
|
|
||||||
|
Returns registry names from `components.json`. Errors if no `components.json` exists.
|
||||||
|
|
||||||
|
**Input:** none
|
||||||
|
|
||||||
|
### `shadcn:list_items_in_registries`
|
||||||
|
|
||||||
|
Lists all items from one or more registries. Registries can be configured
|
||||||
|
namespaces such as `@acme`, public GitHub sources such as `owner/repo`, or
|
||||||
|
registry catalog URLs. Omit `registries` to list from every registry configured
|
||||||
|
in `components.json`.
|
||||||
|
|
||||||
|
**Input:** `registries` (string[], optional — omit for all configured), `types` (string[], optional — e.g. `["ui", "block"]`), `limit` (number, optional, defaults to 100), `offset` (number, optional)
|
||||||
|
|
||||||
|
### `shadcn:search_items_in_registries`
|
||||||
|
|
||||||
|
Fuzzy search across registries. Registries can be configured namespaces, public
|
||||||
|
GitHub sources, or registry catalog URLs. Omit `registries` to search every
|
||||||
|
registry configured in `components.json` — e.g. "find me a hero" across all
|
||||||
|
configured registries.
|
||||||
|
|
||||||
|
**Input:** `registries` (string[], optional — omit for all configured), `query` (string), `types` (string[], optional — e.g. `["ui", "block"]`), `limit` (number, optional, defaults to 100), `offset` (number, optional)
|
||||||
|
|
||||||
|
### `shadcn:view_items_in_registries`
|
||||||
|
|
||||||
|
View item details including full file contents.
|
||||||
|
|
||||||
|
**Input:** `items` (string[]) — e.g.
|
||||||
|
`["@shadcn/button", "@shadcn/card", "owner/repo/item"]`
|
||||||
|
|
||||||
|
### `shadcn:get_item_examples_from_registries`
|
||||||
|
|
||||||
|
Find usage examples and demos with source code. Omit `registries` to search
|
||||||
|
every registry configured in `components.json`.
|
||||||
|
|
||||||
|
**Input:** `registries` (string[], optional — omit for all configured), `query` (string) — e.g. `"accordion-demo"`, `"button example"`
|
||||||
|
|
||||||
|
### `shadcn:get_add_command_for_items`
|
||||||
|
|
||||||
|
Returns the CLI install command.
|
||||||
|
|
||||||
|
**Input:** `items` (string[]) — e.g. `["@shadcn/button"]`
|
||||||
|
|
||||||
|
### `shadcn:get_audit_checklist`
|
||||||
|
|
||||||
|
Returns a checklist for verifying components (imports, deps, lint, TypeScript).
|
||||||
|
|
||||||
|
**Input:** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuring Registries
|
||||||
|
|
||||||
|
Namespaced and authenticated registries are set in `components.json`. The
|
||||||
|
`@shadcn` registry is always built-in. Public GitHub registries can also be used
|
||||||
|
directly as `owner/repo` registry sources when the repository has a root
|
||||||
|
`registry.json`; they do not need `components.json` configuration.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"registries": {
|
||||||
|
"@acme": "https://acme.com/r/{name}.json",
|
||||||
|
"@private": {
|
||||||
|
"url": "https://private.com/r/{name}.json",
|
||||||
|
"headers": { "Authorization": "Bearer ${MY_TOKEN}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Names must start with `@`.
|
||||||
|
- URLs must contain `{name}`.
|
||||||
|
- `${VAR}` references are resolved from environment variables.
|
||||||
|
|
||||||
|
Community registry index: `https://ui.shadcn.com/r/registries.json`
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
# Registry Authoring and Addresses
|
||||||
|
|
||||||
|
Use this reference when the user wants to create, fix, publish, or reason about
|
||||||
|
a shadcn registry.
|
||||||
|
|
||||||
|
## Mental Model
|
||||||
|
|
||||||
|
A registry has two forms:
|
||||||
|
|
||||||
|
- **Source registry**: an authored `registry.json` in a project or repository.
|
||||||
|
It may use `include` and file paths that point at source files.
|
||||||
|
- **Built registry**: generated JSON files served to CLI consumers, usually
|
||||||
|
from `public/r`. Use `npx shadcn@latest build` to create this form.
|
||||||
|
|
||||||
|
The CLI installer consumes registry item payloads. A source registry is a way to
|
||||||
|
author those payloads from real files.
|
||||||
|
|
||||||
|
Registry items are not limited to React components. They can distribute
|
||||||
|
components, hooks, utilities, design tokens, pages, config files, docs, rules,
|
||||||
|
workflows, templates, MCP files, and other project files.
|
||||||
|
|
||||||
|
## Root `registry.json`
|
||||||
|
|
||||||
|
The root registry file should define registry metadata and either `items` or
|
||||||
|
`include`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||||
|
"name": "acme",
|
||||||
|
"homepage": "https://acme.com",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "absolute-url",
|
||||||
|
"type": "registry:lib",
|
||||||
|
"title": "Absolute URL",
|
||||||
|
"description": "A utility to turn any path into an absolute URL.",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "lib/absolute-url.ts",
|
||||||
|
"type": "registry:lib"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Root registry rules:
|
||||||
|
|
||||||
|
- Root `registry.json` must include `name` and `homepage`.
|
||||||
|
- `items` is an array of registry item definitions.
|
||||||
|
- `include` may be used to split the source registry into multiple files.
|
||||||
|
- Included registry files may omit `name` and `homepage`.
|
||||||
|
|
||||||
|
## Include
|
||||||
|
|
||||||
|
Use `include` to keep large registries modular.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||||
|
"name": "acme",
|
||||||
|
"homepage": "https://acme.com",
|
||||||
|
"include": ["registry/ui/registry.json", "registry/blocks/registry.json"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Include rules:
|
||||||
|
|
||||||
|
- Include paths are relative to the `registry.json` that declares them.
|
||||||
|
- Include paths must explicitly point to a `registry.json` file.
|
||||||
|
- Do not use remote URLs, absolute paths, or parent traversal (`..`).
|
||||||
|
- Item file paths are relative to the registry file that declares the item.
|
||||||
|
- Duplicate item names fail across the resolved registry.
|
||||||
|
|
||||||
|
Example included file:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "button",
|
||||||
|
"type": "registry:ui",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "button.tsx",
|
||||||
|
"type": "registry:ui"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If this file is at `registry/ui/registry.json`, then `button.tsx` is read from
|
||||||
|
`registry/ui/button.tsx`, and the built item path is emitted relative to the
|
||||||
|
root registry.
|
||||||
|
|
||||||
|
## Item Definitions
|
||||||
|
|
||||||
|
Common item fields:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "login-form",
|
||||||
|
"type": "registry:block",
|
||||||
|
"title": "Login Form",
|
||||||
|
"description": "A login form with email and password fields.",
|
||||||
|
"dependencies": ["zod"],
|
||||||
|
"registryDependencies": ["button", "input", "label"],
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "blocks/login-form.tsx",
|
||||||
|
"type": "registry:block"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cssVars": {
|
||||||
|
"light": {
|
||||||
|
"brand": "oklch(0.62 0.18 250)"
|
||||||
|
},
|
||||||
|
"dark": {
|
||||||
|
"brand": "oklch(0.72 0.16 250)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Important fields:
|
||||||
|
|
||||||
|
- `name`: the installable item name. It is not necessarily a file path.
|
||||||
|
- `type`: one of the registry item types, such as `registry:ui`,
|
||||||
|
`registry:block`, `registry:lib`, `registry:hook`, `registry:file`,
|
||||||
|
`registry:page`, `registry:theme`, `registry:style`, `registry:font`, or
|
||||||
|
`registry:item`.
|
||||||
|
- `files`: source files copied or generated by the item.
|
||||||
|
- `dependencies`: npm runtime dependencies.
|
||||||
|
- `devDependencies`: npm development dependencies.
|
||||||
|
- `registryDependencies`: other registry items required by this item.
|
||||||
|
- `cssVars`, `css`, `tailwind`, `envVars`, and `docs`: optional install-time
|
||||||
|
additions.
|
||||||
|
|
||||||
|
File rules:
|
||||||
|
|
||||||
|
- File paths are relative to the declaring `registry.json`.
|
||||||
|
- `registry:file` and `registry:page` files require a `target`.
|
||||||
|
- Do not use remote file URLs in source registry file paths.
|
||||||
|
- Keep source files copy-pasteable: no hidden app-only imports.
|
||||||
|
|
||||||
|
## Registry Dependencies
|
||||||
|
|
||||||
|
`registryDependencies` entries are item addresses, not file paths.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "login-form",
|
||||||
|
"type": "registry:block",
|
||||||
|
"registryDependencies": ["button", "@acme/input", "acme/ui/card#v1.2.0"],
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "blocks/login-form.tsx",
|
||||||
|
"type": "registry:block"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Dependency rules:
|
||||||
|
|
||||||
|
- Bare names such as `"button"` mean official shadcn items.
|
||||||
|
- Bare names never mean same-registry or same-repository items.
|
||||||
|
- Namespaced dependencies use `@namespace/item-name`.
|
||||||
|
- GitHub dependencies use `owner/repo/item-name`.
|
||||||
|
- Pin GitHub dependencies with `owner/repo/item-name#ref` when needed.
|
||||||
|
- Refs are not inherited. If `owner/repo/foo#v2` depends on `bar` from the same
|
||||||
|
repo at `v2`, write `owner/repo/bar#v2`.
|
||||||
|
- Do not use relative dependencies such as `"./bar"`.
|
||||||
|
|
||||||
|
## Address Schemes
|
||||||
|
|
||||||
|
When reasoning about a registry item string, classify it first.
|
||||||
|
|
||||||
|
| Address | Scheme | Meaning |
|
||||||
|
| ----------------------------------- | --------- | ------------------------------------------------------------ |
|
||||||
|
| `button` | shadcn | Official shadcn item named `button`. |
|
||||||
|
| `@acme/button` | namespace | Item `button` from configured registry `@acme`. |
|
||||||
|
| `@acme/ui/button` | namespace | Item `ui/button` from configured registry `@acme`. |
|
||||||
|
| `https://example.com/r/button.json` | url | Built registry item JSON at that URL. |
|
||||||
|
| `./button.json` | file | Built registry item JSON on disk. |
|
||||||
|
| `acme/ui/button` | github | Item `button` from GitHub repo `acme/ui`. |
|
||||||
|
| `acme/ui/forms/login#main` | github | Item `forms/login` from GitHub repo `acme/ui` at ref `main`. |
|
||||||
|
|
||||||
|
For namespace and GitHub addresses, slashful item names are allowed and are item
|
||||||
|
names, not file paths. Addresses ending in `.json` keep file-address
|
||||||
|
precedence, so `acme/ui/data/schema.json` is treated as a file path, not a
|
||||||
|
GitHub item address.
|
||||||
|
|
||||||
|
## GitHub Registries
|
||||||
|
|
||||||
|
A public GitHub repository can act as a source registry when it has a root
|
||||||
|
`registry.json`.
|
||||||
|
|
||||||
|
```txt
|
||||||
|
owner/repo/item-name[#ref]
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- The first two path segments are GitHub owner and repo.
|
||||||
|
- All remaining path segments are the registry item name.
|
||||||
|
- The source entrypoint is always root `registry.json`.
|
||||||
|
- GitHub registries are source registries consumed directly by the CLI. They do
|
||||||
|
not require `shadcn build` or generated item JSON files.
|
||||||
|
- `include` follows the same source-registry rules as local registries.
|
||||||
|
- Currently, GitHub addresses support public `github.com` repositories only.
|
||||||
|
- Private repos and GitHub Enterprise require explicit product decisions.
|
||||||
|
|
||||||
|
When implementing GitHub registry fetching, resolve refs to a commit SHA before
|
||||||
|
reading source files. Do not read moving refs directly from
|
||||||
|
`raw.githubusercontent.com`, because branch-like refs can be cached for several
|
||||||
|
minutes.
|
||||||
|
|
||||||
|
Preferred flow:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
owner/repo[#ref]
|
||||||
|
-> resolve ref with git ls-remote
|
||||||
|
-> commit SHA
|
||||||
|
-> read https://raw.githubusercontent.com/{owner}/{repo}/{sha}/registry.json
|
||||||
|
-> read includes and item files from the same SHA
|
||||||
|
```
|
||||||
|
|
||||||
|
This keeps a command on one consistent repository snapshot.
|
||||||
|
|
||||||
|
Full 40-character commit SHAs are already stable and can be used directly.
|
||||||
|
Branches, tags, and short refs require Git so the CLI can resolve them to a
|
||||||
|
commit SHA first.
|
||||||
|
|
||||||
|
## Build and Verify
|
||||||
|
|
||||||
|
Use the CLI to build source registries:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest build
|
||||||
|
npx shadcn@latest build registry.json --output public/r
|
||||||
|
```
|
||||||
|
|
||||||
|
Use CLI commands to inspect the result:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest list @acme
|
||||||
|
npx shadcn@latest search @acme -q "login"
|
||||||
|
npx shadcn@latest view @acme/login-form
|
||||||
|
npx shadcn@latest add @acme/login-form --dry-run
|
||||||
|
npx shadcn@latest registry validate ./registry.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Use GitHub addresses directly for public GitHub registries:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn@latest list owner/repo
|
||||||
|
npx shadcn@latest search owner/repo -q "login"
|
||||||
|
npx shadcn@latest view owner/repo/item
|
||||||
|
npx shadcn@latest add owner/repo/item --dry-run
|
||||||
|
npx shadcn@latest registry validate owner/repo
|
||||||
|
```
|
||||||
|
|
||||||
|
When working on registry implementation in the shadcn/ui codebase:
|
||||||
|
|
||||||
|
- Keep address parsing pure and testable.
|
||||||
|
- Do not add side effects to validators.
|
||||||
|
- Preserve existing behavior for official shadcn, namespace, URL, and file
|
||||||
|
schemes.
|
||||||
|
- Add tests for address parsing, source loading, dependency resolution, list,
|
||||||
|
search, view, and add paths.
|
||||||
|
- Prefer small source-reader abstractions over a plugin system until there are
|
||||||
|
multiple real providers.
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
# Base vs Radix
|
||||||
|
|
||||||
|
API differences between `base` and `radix`. Check the `base` field from `npx shadcn@latest info`.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- Composition: asChild vs render
|
||||||
|
- Button / trigger as non-button element
|
||||||
|
- Select (items prop, placeholder, positioning, multiple, object values)
|
||||||
|
- ToggleGroup (type vs multiple)
|
||||||
|
- Slider (scalar vs array)
|
||||||
|
- Accordion (type and defaultValue)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Composition: asChild (radix) vs render (base)
|
||||||
|
|
||||||
|
Radix uses `asChild` to replace the default element. Base uses `render`. Don't wrap triggers in extra elements.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<DialogTrigger>
|
||||||
|
<div>
|
||||||
|
<Button>Open</Button>
|
||||||
|
</div>
|
||||||
|
</DialogTrigger>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (radix):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>Open</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<DialogTrigger render={<Button />}>Open</DialogTrigger>
|
||||||
|
```
|
||||||
|
|
||||||
|
This applies to all trigger and close components: `DialogTrigger`, `SheetTrigger`, `AlertDialogTrigger`, `DropdownMenuTrigger`, `PopoverTrigger`, `TooltipTrigger`, `CollapsibleTrigger`, `DialogClose`, `SheetClose`, `NavigationMenuLink`, `BreadcrumbLink`, `SidebarMenuButton`, `Badge`, `Item`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Button / trigger as non-button element (base only)
|
||||||
|
|
||||||
|
When `render` changes an element to a non-button (`<a>`, `<span>`), add `nativeButton={false}`.
|
||||||
|
|
||||||
|
**Incorrect (base):** missing `nativeButton={false}`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button render={<a href="/docs" />}>Read the docs</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button render={<a href="/docs" />} nativeButton={false}>
|
||||||
|
Read the docs
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (radix):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button asChild>
|
||||||
|
<a href="/docs">Read the docs</a>
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Same for triggers whose `render` is not a `Button`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// base.
|
||||||
|
<PopoverTrigger render={<InputGroupAddon />} nativeButton={false}>
|
||||||
|
Pick date
|
||||||
|
</PopoverTrigger>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Select
|
||||||
|
|
||||||
|
**items prop (base only).** Base requires an `items` prop on the root. Radix uses inline JSX only.
|
||||||
|
|
||||||
|
**Incorrect (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Select>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select a fruit" /></SelectTrigger>
|
||||||
|
</Select>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const items = [
|
||||||
|
{ label: "Select a fruit", value: null },
|
||||||
|
{ label: "Apple", value: "apple" },
|
||||||
|
{ label: "Banana", value: "banana" },
|
||||||
|
]
|
||||||
|
|
||||||
|
<Select items={items}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{items.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (radix):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Select>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a fruit" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="apple">Apple</SelectItem>
|
||||||
|
<SelectItem value="banana">Banana</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Placeholder.** Base uses a `{ value: null }` item in the items array. Radix uses `<SelectValue placeholder="...">`.
|
||||||
|
|
||||||
|
**Content positioning.** Base uses `alignItemWithTrigger`. Radix uses `position`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// base.
|
||||||
|
<SelectContent alignItemWithTrigger={false} side="bottom">
|
||||||
|
|
||||||
|
// radix.
|
||||||
|
<SelectContent position="popper">
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Select — multiple selection and object values (base only)
|
||||||
|
|
||||||
|
Base supports `multiple`, render-function children on `SelectValue`, and object values with `itemToStringValue`. Radix is single-select with string values only.
|
||||||
|
|
||||||
|
**Correct (base — multiple selection):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Select items={items} multiple defaultValue={[]}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue>
|
||||||
|
{(value: string[]) => value.length === 0 ? "Select fruits" : `${value.length} selected`}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
...
|
||||||
|
</Select>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (base — object values):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Select defaultValue={plans[0]} itemToStringValue={(plan) => plan.name}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue>{(value) => value.name}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
...
|
||||||
|
</Select>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ToggleGroup
|
||||||
|
|
||||||
|
Base uses a `multiple` boolean prop. Radix uses `type="single"` or `type="multiple"`.
|
||||||
|
|
||||||
|
**Incorrect (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ToggleGroup type="single" defaultValue="daily">
|
||||||
|
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Single (no prop needed), defaultValue is always an array.
|
||||||
|
<ToggleGroup defaultValue={["daily"]} spacing={2}>
|
||||||
|
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
|
||||||
|
// Multi-selection.
|
||||||
|
<ToggleGroup multiple>
|
||||||
|
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (radix):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Single, defaultValue is a string.
|
||||||
|
<ToggleGroup type="single" defaultValue="daily" spacing={2}>
|
||||||
|
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
|
||||||
|
// Multi-selection.
|
||||||
|
<ToggleGroup type="multiple">
|
||||||
|
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Controlled single value:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// base — wrap/unwrap arrays.
|
||||||
|
const [value, setValue] = React.useState("normal")
|
||||||
|
<ToggleGroup value={[value]} onValueChange={(v) => setValue(v[0])}>
|
||||||
|
|
||||||
|
// radix — plain string.
|
||||||
|
const [value, setValue] = React.useState("normal")
|
||||||
|
<ToggleGroup type="single" value={value} onValueChange={setValue}>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slider
|
||||||
|
|
||||||
|
Base accepts a plain number for a single thumb. Radix always requires an array.
|
||||||
|
|
||||||
|
**Incorrect (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Slider defaultValue={[50]} max={100} step={1} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Slider defaultValue={50} max={100} step={1} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (radix):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Slider defaultValue={[50]} max={100} step={1} />
|
||||||
|
```
|
||||||
|
|
||||||
|
Both use arrays for range sliders. Controlled `onValueChange` in base may need a cast:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// base.
|
||||||
|
const [value, setValue] = React.useState([0.3, 0.7])
|
||||||
|
<Slider value={value} onValueChange={(v) => setValue(v as number[])} />
|
||||||
|
|
||||||
|
// radix.
|
||||||
|
const [value, setValue] = React.useState([0.3, 0.7])
|
||||||
|
<Slider value={value} onValueChange={setValue} />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accordion
|
||||||
|
|
||||||
|
Radix requires `type="single"` or `type="multiple"` and supports `collapsible`. `defaultValue` is a string. Base uses no `type` prop, uses `multiple` boolean, and `defaultValue` is always an array.
|
||||||
|
|
||||||
|
**Incorrect (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Accordion type="single" collapsible defaultValue="item-1">
|
||||||
|
<AccordionItem value="item-1">...</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (base):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Accordion defaultValue={["item-1"]}>
|
||||||
|
<AccordionItem value="item-1">...</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
// Multi-select.
|
||||||
|
<Accordion multiple defaultValue={["item-1", "item-2"]}>
|
||||||
|
<AccordionItem value="item-1">...</AccordionItem>
|
||||||
|
<AccordionItem value="item-2">...</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (radix):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Accordion type="single" collapsible defaultValue="item-1">
|
||||||
|
<AccordionItem value="item-1">...</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
```
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
# Chat & Messaging
|
||||||
|
|
||||||
|
Components for conversation and chat UI. Compose these instead of hand-rolling
|
||||||
|
bubbles, scroll containers, dividers, or attachment cards.
|
||||||
|
|
||||||
|
Install: `npx shadcn@latest add message-scroller message bubble attachment marker`
|
||||||
|
|
||||||
|
The same component names and props ship for both `base` and `radix`; only
|
||||||
|
composition differs (`render` vs `asChild`). See [base-vs-radix.md](./base-vs-radix.md).
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- Scrollable threads use MessageScroller
|
||||||
|
- Message rows use Message
|
||||||
|
- Message surfaces use Bubble
|
||||||
|
- Attachments use Attachment
|
||||||
|
- System notes and dividers use Marker
|
||||||
|
- Streaming, anchoring, and jump-to-latest are built in
|
||||||
|
- Escape hatch: the scroller hooks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scrollable threads use MessageScroller
|
||||||
|
|
||||||
|
A conversation that scrolls, follows new messages, restores position, or jumps
|
||||||
|
to a message uses `MessageScroller`. Don't build a raw overflow container with
|
||||||
|
manual scroll wiring, and don't reach for `ScrollArea`.
|
||||||
|
|
||||||
|
The parts nest in a fixed order. Every direct child of the content is wrapped in
|
||||||
|
a `MessageScrollerItem` so the scroller can measure, anchor, preserve position,
|
||||||
|
track visibility, and jump to it. `MessageScrollerButton` sits inside
|
||||||
|
`MessageScroller`, after the viewport.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Hand-rolled scroll container with manual stick-to-bottom logic.
|
||||||
|
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto">
|
||||||
|
<div className="flex flex-col gap-6 p-4">
|
||||||
|
{messages.map((m) => (
|
||||||
|
<ChatMessage key={m.id} message={m} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<MessageScrollerProvider autoScroll>
|
||||||
|
<MessageScroller>
|
||||||
|
<MessageScrollerViewport>
|
||||||
|
<MessageScrollerContent>
|
||||||
|
{messages.map((message) => (
|
||||||
|
<MessageScrollerItem
|
||||||
|
key={message.id}
|
||||||
|
messageId={message.id}
|
||||||
|
scrollAnchor={message.role === "user"}
|
||||||
|
>
|
||||||
|
<Message align={message.role === "user" ? "end" : "start"}>
|
||||||
|
{/* ...message content... */}
|
||||||
|
</Message>
|
||||||
|
</MessageScrollerItem>
|
||||||
|
))}
|
||||||
|
</MessageScrollerContent>
|
||||||
|
</MessageScrollerViewport>
|
||||||
|
<MessageScrollerButton />
|
||||||
|
</MessageScroller>
|
||||||
|
</MessageScrollerProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Message rows use Message
|
||||||
|
|
||||||
|
`Message` lays out a single row: avatar, header, content, footer, with
|
||||||
|
alignment. Group consecutive rows from one sender with `MessageGroup`. Don't
|
||||||
|
rebuild the row from flex divs.
|
||||||
|
|
||||||
|
`align="end"` is the current user's side; `align="start"` is everyone else.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Message align="start">
|
||||||
|
<MessageAvatar>
|
||||||
|
<Avatar>
|
||||||
|
<AvatarImage src={sender.avatar} alt={sender.name} />
|
||||||
|
<AvatarFallback>{initials}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</MessageAvatar>
|
||||||
|
<MessageContent>
|
||||||
|
<MessageHeader>{sender.name}</MessageHeader>
|
||||||
|
<Bubble>
|
||||||
|
<BubbleContent>{text}</BubbleContent>
|
||||||
|
</Bubble>
|
||||||
|
<MessageFooter>{time}</MessageFooter>
|
||||||
|
</MessageContent>
|
||||||
|
</Message>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Message surfaces use Bubble
|
||||||
|
|
||||||
|
The colored message surface is `Bubble` + `BubbleContent`, never a styled `div`
|
||||||
|
with `bg-muted` / `bg-primary` and hand-managed corners.
|
||||||
|
|
||||||
|
- `variant`: `default`, `secondary`, `muted`, `tinted`, `outline`, `ghost`, `destructive`.
|
||||||
|
- `align`: `start` or `end` (matches the `Message` side).
|
||||||
|
|
||||||
|
`BubbleReactions` renders the reaction cluster. `side` (`top` | `bottom`) and
|
||||||
|
`align` (`start` | `end`) position it against the bubble. Don't lay reactions out
|
||||||
|
with absolutely-positioned `Badge`s.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="w-fit rounded-2xl bg-primary px-3 py-2 text-primary-foreground">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Bubble variant="default" align="end">
|
||||||
|
<BubbleContent>{text}</BubbleContent>
|
||||||
|
<BubbleReactions side="bottom" align="end">
|
||||||
|
<Badge variant="secondary">👍 2</Badge>
|
||||||
|
</BubbleReactions>
|
||||||
|
</Bubble>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Attachments use Attachment
|
||||||
|
|
||||||
|
File and image attachments use `Attachment`, not `Item` or a custom card. It
|
||||||
|
carries upload state, so wire `state` to the real status rather than rendering a
|
||||||
|
separate spinner.
|
||||||
|
|
||||||
|
- `state`: `idle`, `uploading`, `processing`, `error`, `done`. `uploading` and
|
||||||
|
`processing` apply the `shimmer` animation to the title automatically.
|
||||||
|
- `size`: `default`, `sm`, `xs`. `orientation`: `horizontal`, `vertical`.
|
||||||
|
- Use `AttachmentGroup` to lay out several attachments in a scrolling row.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Attachment state="done">
|
||||||
|
<AttachmentMedia variant="icon">
|
||||||
|
<FileTextIcon />
|
||||||
|
</AttachmentMedia>
|
||||||
|
<AttachmentContent>
|
||||||
|
<AttachmentTitle>homepage-feedback.pdf</AttachmentTitle>
|
||||||
|
<AttachmentDescription>PDF · 2.4 MB</AttachmentDescription>
|
||||||
|
</AttachmentContent>
|
||||||
|
<AttachmentActions>
|
||||||
|
<AttachmentAction>
|
||||||
|
<DownloadIcon />
|
||||||
|
</AttachmentAction>
|
||||||
|
</AttachmentActions>
|
||||||
|
</Attachment>
|
||||||
|
```
|
||||||
|
|
||||||
|
For an image, use `<AttachmentMedia variant="image">` with an `img` child.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## System notes and dividers use Marker
|
||||||
|
|
||||||
|
Status lines ("Sarah joined the conversation"), date dividers ("Today"), and
|
||||||
|
labeled separators are `Marker`, not a `Separator` plus a centered span.
|
||||||
|
|
||||||
|
- `variant`: `default` (plain row), `separator` (centered label with rules on
|
||||||
|
each side), `border` (bottom-bordered row).
|
||||||
|
- `MarkerIcon` holds a leading icon; `MarkerContent` holds the label.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="flex items-center gap-3 py-2">
|
||||||
|
<Separator className="flex-1" />
|
||||||
|
<span className="text-xs text-muted-foreground">Today</span>
|
||||||
|
<Separator className="flex-1" />
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Marker variant="separator">
|
||||||
|
<MarkerContent>Today</MarkerContent>
|
||||||
|
</Marker>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Streaming, anchoring, and jump-to-latest are built in
|
||||||
|
|
||||||
|
`MessageScroller` handles the behavior that chat UIs usually reinvent. Don't
|
||||||
|
write a `useStickToBottom` hook, a `ResizeObserver`, or manual `scrollTop` math.
|
||||||
|
|
||||||
|
- **Follow the live edge while streaming.** `MessageScrollerProvider` with
|
||||||
|
`autoScroll` keeps the view pinned to new content and yields the moment the
|
||||||
|
user scrolls up. Streaming token updates that grow the last message are
|
||||||
|
followed automatically.
|
||||||
|
- **Anchor a turn.** `scrollAnchor` on a `MessageScrollerItem` marks the row to
|
||||||
|
hold in view (typically the user's message that started the turn).
|
||||||
|
- **Jump to latest.** `MessageScrollerButton` appears when the user scrolls away
|
||||||
|
and scrolls back on click. `direction="end"` (default) or `direction="start"`.
|
||||||
|
It is a self-managing control, so don't gate it behind your own scroll-position
|
||||||
|
state.
|
||||||
|
|
||||||
|
For a "thinking…" indicator while the model generates, apply the `shimmer`
|
||||||
|
utility to text. Don't author a custom keyframe animation. See
|
||||||
|
[styling.md](./styling.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Escape hatch: the scroller hooks
|
||||||
|
|
||||||
|
For behavior the parts don't expose, read state from the hooks rather than
|
||||||
|
re-implementing the scroller: `useMessageScroller`,
|
||||||
|
`useMessageScrollerVisibility`, and `useMessageScrollerScrollable`. They come
|
||||||
|
from the auto-installed `@shadcn/react` dependency, so there's nothing extra to
|
||||||
|
install. Reach for them only when composition can't express what you need.
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
# Component Composition
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- Items always inside their Group component
|
||||||
|
- Callouts use Alert
|
||||||
|
- Empty states use Empty component
|
||||||
|
- Toast notifications follow the project base
|
||||||
|
- Choosing between overlay components
|
||||||
|
- Dialog, Sheet, and Drawer always need a Title
|
||||||
|
- Card structure
|
||||||
|
- Button has no isPending or isLoading prop
|
||||||
|
- TabsTrigger must be inside TabsList
|
||||||
|
- Avatar always needs AvatarFallback
|
||||||
|
- Use Separator instead of raw hr or border divs
|
||||||
|
- Use Skeleton for loading placeholders
|
||||||
|
- Use Badge instead of custom styled spans
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Items always inside their Group component
|
||||||
|
|
||||||
|
Never render items directly inside the content container.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="apple">Apple</SelectItem>
|
||||||
|
<SelectItem value="banana">Banana</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="apple">Apple</SelectItem>
|
||||||
|
<SelectItem value="banana">Banana</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
```
|
||||||
|
|
||||||
|
This applies to all group-based components:
|
||||||
|
|
||||||
|
| Item | Group |
|
||||||
|
|------|-------|
|
||||||
|
| `SelectItem`, `SelectLabel` | `SelectGroup` |
|
||||||
|
| `DropdownMenuItem`, `DropdownMenuLabel`, `DropdownMenuSub` | `DropdownMenuGroup` |
|
||||||
|
| `MenubarItem` | `MenubarGroup` |
|
||||||
|
| `ContextMenuItem` | `ContextMenuGroup` |
|
||||||
|
| `CommandItem` | `CommandGroup` |
|
||||||
|
| `MessageScrollerItem` | `MessageScrollerContent` |
|
||||||
|
| `Message` (consecutive, same sender) | `MessageGroup` |
|
||||||
|
| `Bubble` (stacked) | `BubbleGroup` |
|
||||||
|
| `Attachment` (in a row) | `AttachmentGroup` |
|
||||||
|
|
||||||
|
Chat components nest in a fixed order (`MessageScrollerProvider` → `MessageScroller` → `MessageScrollerViewport` → `MessageScrollerContent` → `MessageScrollerItem`). See [chat.md](./chat.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Callouts use Alert
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Alert>
|
||||||
|
<AlertTitle>Warning</AlertTitle>
|
||||||
|
<AlertDescription>Something needs attention.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Empty states use Empty component
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Empty>
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyMedia variant="icon"><FolderIcon /></EmptyMedia>
|
||||||
|
<EmptyTitle>No projects yet</EmptyTitle>
|
||||||
|
<EmptyDescription>Get started by creating a new project.</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
<EmptyContent>
|
||||||
|
<Button>Create Project</Button>
|
||||||
|
</EmptyContent>
|
||||||
|
</Empty>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Toast notifications follow the project base
|
||||||
|
|
||||||
|
For Base UI projects, use the `toast` component:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { toast } from "@/components/ui/toast"
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
title: "Changes saved.",
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
For Radix and React Aria projects, use Sonner:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
toast.success("Changes saved.")
|
||||||
|
toast.error("Something went wrong.")
|
||||||
|
toast("File deleted.", {
|
||||||
|
action: { label: "Undo", onClick: () => undoDelete() },
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Choosing between overlay components
|
||||||
|
|
||||||
|
| Use case | Component |
|
||||||
|
|----------|-----------|
|
||||||
|
| Focused task that requires input | `Dialog` |
|
||||||
|
| Destructive action confirmation | `AlertDialog` |
|
||||||
|
| Side panel with details or filters | `Sheet` |
|
||||||
|
| Mobile-first bottom panel | `Drawer` |
|
||||||
|
| Quick info on hover | `HoverCard` |
|
||||||
|
| Small contextual content on click | `Popover` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dialog, Sheet, and Drawer always need a Title
|
||||||
|
|
||||||
|
`DialogTitle`, `SheetTitle`, `DrawerTitle` are required for accessibility. Use `className="sr-only"` if visually hidden.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Profile</DialogTitle>
|
||||||
|
<DialogDescription>Update your profile.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
...
|
||||||
|
</DialogContent>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Card structure
|
||||||
|
|
||||||
|
Use full composition — don't dump everything into `CardContent`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Team Members</CardTitle>
|
||||||
|
<CardDescription>Manage your team.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>...</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button>Invite</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Button has no isPending or isLoading prop
|
||||||
|
|
||||||
|
Compose with `Spinner` + `data-icon` + `disabled`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button disabled>
|
||||||
|
<Spinner data-icon="inline-start" />
|
||||||
|
Saving...
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TabsTrigger must be inside TabsList
|
||||||
|
|
||||||
|
Never render `TabsTrigger` directly inside `Tabs` — always wrap in `TabsList`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Tabs defaultValue="account">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="account">Account</TabsTrigger>
|
||||||
|
<TabsTrigger value="password">Password</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="account">...</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Avatar always needs AvatarFallback
|
||||||
|
|
||||||
|
Always include `AvatarFallback` for when the image fails to load:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Avatar>
|
||||||
|
<AvatarImage src="/avatar.png" alt="User" />
|
||||||
|
<AvatarFallback>JD</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use existing components instead of custom markup
|
||||||
|
|
||||||
|
| Instead of | Use |
|
||||||
|
|---|---|
|
||||||
|
| `<hr>` or `<div className="border-t">` | `<Separator />` |
|
||||||
|
| `<div className="animate-pulse">` with styled divs | `<Skeleton className="h-4 w-3/4" />` |
|
||||||
|
| `<span className="rounded-full bg-green-100 ...">` | `<Badge variant="secondary">` |
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
# Forms & Inputs
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- Forms use FieldGroup + Field
|
||||||
|
- InputGroup requires InputGroupInput/InputGroupTextarea
|
||||||
|
- Buttons inside inputs use InputGroup + InputGroupAddon
|
||||||
|
- Option sets (2–7 choices) use ToggleGroup
|
||||||
|
- FieldSet + FieldLegend for grouping related fields
|
||||||
|
- Field validation and disabled states
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Forms use FieldGroup + Field
|
||||||
|
|
||||||
|
Always use `FieldGroup` + `Field` — never raw `div` with `space-y-*`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||||
|
<Input id="email" type="email" />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="password">Password</FieldLabel>
|
||||||
|
<Input id="password" type="password" />
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `Field orientation="horizontal"` for settings pages. Use `FieldLabel className="sr-only"` for visually hidden labels.
|
||||||
|
|
||||||
|
**Choosing form controls:**
|
||||||
|
|
||||||
|
- Simple text input → `Input`
|
||||||
|
- Dropdown with predefined options → `Select`
|
||||||
|
- Searchable dropdown → `Combobox`
|
||||||
|
- Native HTML select (no JS) → `native-select`
|
||||||
|
- Boolean toggle → `Switch` (for settings) or `Checkbox` (for forms)
|
||||||
|
- Single choice from few options → `RadioGroup`
|
||||||
|
- Toggle between 2–5 options → `ToggleGroup` + `ToggleGroupItem`
|
||||||
|
- OTP/verification code → `InputOTP`
|
||||||
|
- Multi-line text → `Textarea`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## InputGroup requires InputGroupInput/InputGroupTextarea
|
||||||
|
|
||||||
|
Never use raw `Input` or `Textarea` inside an `InputGroup`.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<InputGroup>
|
||||||
|
<Input placeholder="Search..." />
|
||||||
|
</InputGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { InputGroup, InputGroupInput } from "@/components/ui/input-group"
|
||||||
|
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupInput placeholder="Search..." />
|
||||||
|
</InputGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Buttons inside inputs use InputGroup + InputGroupAddon
|
||||||
|
|
||||||
|
Never place a `Button` directly inside or adjacent to an `Input` with custom positioning.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="relative">
|
||||||
|
<Input placeholder="Search..." className="pr-10" />
|
||||||
|
<Button className="absolute right-0 top-0" size="icon">
|
||||||
|
<SearchIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { InputGroup, InputGroupInput, InputGroupAddon } from "@/components/ui/input-group"
|
||||||
|
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupInput placeholder="Search..." />
|
||||||
|
<InputGroupAddon>
|
||||||
|
<Button size="icon">
|
||||||
|
<SearchIcon data-icon="inline-start" />
|
||||||
|
</Button>
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Option sets (2–7 choices) use ToggleGroup
|
||||||
|
|
||||||
|
Don't manually loop `Button` components with active state.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [selected, setSelected] = useState("daily")
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{["daily", "weekly", "monthly"].map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option}
|
||||||
|
variant={selected === option ? "default" : "outline"}
|
||||||
|
onClick={() => setSelected(option)}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||||
|
|
||||||
|
<ToggleGroup spacing={2}>
|
||||||
|
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="monthly">Monthly</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
Combine with `Field` for labelled toggle groups:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<FieldTitle id="theme-label">Theme</FieldTitle>
|
||||||
|
<ToggleGroup aria-labelledby="theme-label" spacing={2}>
|
||||||
|
<ToggleGroupItem value="light">Light</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="dark">Dark</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="system">System</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
</Field>
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** `defaultValue` and `type`/`multiple` props differ between base and radix. See [base-vs-radix.md](./base-vs-radix.md#togglegroup).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FieldSet + FieldLegend for grouping related fields
|
||||||
|
|
||||||
|
Use `FieldSet` + `FieldLegend` for related checkboxes, radios, or switches — not `div` with a heading:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<FieldSet>
|
||||||
|
<FieldLegend variant="label">Preferences</FieldLegend>
|
||||||
|
<FieldDescription>Select all that apply.</FieldDescription>
|
||||||
|
<FieldGroup className="gap-3">
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<Checkbox id="dark" />
|
||||||
|
<FieldLabel htmlFor="dark" className="font-normal">Dark mode</FieldLabel>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</FieldSet>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Field validation and disabled states
|
||||||
|
|
||||||
|
Both attributes are needed — `data-invalid`/`data-disabled` styles the field (label, description), while `aria-invalid`/`disabled` styles the control.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Invalid.
|
||||||
|
<Field data-invalid>
|
||||||
|
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||||
|
<Input id="email" aria-invalid />
|
||||||
|
<FieldDescription>Invalid email address.</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
// Disabled.
|
||||||
|
<Field data-disabled>
|
||||||
|
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||||
|
<Input id="email" disabled />
|
||||||
|
</Field>
|
||||||
|
```
|
||||||
|
|
||||||
|
Works for all controls: `Input`, `Textarea`, `Select`, `Checkbox`, `RadioGroupItem`, `Switch`, `Slider`, `NativeSelect`, `InputOTP`.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Icons
|
||||||
|
|
||||||
|
**Always use the project's configured `iconLibrary` for imports.** Check the `iconLibrary` field from project context: `lucide` → `lucide-react`, `tabler` → `@tabler/icons-react`, etc. Never assume `lucide-react`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Icons in Button use data-icon attribute
|
||||||
|
|
||||||
|
Add `data-icon="inline-start"` (prefix) or `data-icon="inline-end"` (suffix) to the icon. No sizing classes on the icon.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button>
|
||||||
|
<SearchIcon className="mr-2 size-4" />
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button>
|
||||||
|
<SearchIcon data-icon="inline-start"/>
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button>
|
||||||
|
Next
|
||||||
|
<ArrowRightIcon data-icon="inline-end"/>
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## No sizing classes on icons inside components
|
||||||
|
|
||||||
|
Components handle icon sizing via CSS. Don't add `size-4`, `w-4 h-4`, or other sizing classes to icons inside `Button`, `DropdownMenuItem`, `Alert`, `Sidebar*`, or other shadcn components. Unless the user explicitly asks for custom icon sizes.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button>
|
||||||
|
<SearchIcon className="size-4" data-icon="inline-start" />
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<SettingsIcon className="mr-2 size-4" />
|
||||||
|
Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button>
|
||||||
|
<SearchIcon data-icon="inline-start" />
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<SettingsIcon />
|
||||||
|
Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pass icons as component objects, not string keys
|
||||||
|
|
||||||
|
Use `icon={CheckIcon}`, not a string key to a lookup map.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const iconMap = {
|
||||||
|
check: CheckIcon,
|
||||||
|
alert: AlertIcon,
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ icon }: { icon: string }) {
|
||||||
|
const Icon = iconMap[icon]
|
||||||
|
return <Icon />
|
||||||
|
}
|
||||||
|
|
||||||
|
<StatusBadge icon="check" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Import from the project's configured iconLibrary (e.g. lucide-react, @tabler/icons-react).
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function StatusBadge({ icon: Icon }: { icon: React.ComponentType }) {
|
||||||
|
return <Icon />
|
||||||
|
}
|
||||||
|
|
||||||
|
<StatusBadge icon={CheckIcon} />
|
||||||
|
```
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
# Styling & Customization
|
||||||
|
|
||||||
|
See [customization.md](../customization.md) for theming, CSS variables, and adding custom colors.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- Semantic colors
|
||||||
|
- Built-in variants first
|
||||||
|
- className for layout only
|
||||||
|
- No space-x-* / space-y-*
|
||||||
|
- Prefer size-* over w-* h-* when equal
|
||||||
|
- Prefer truncate shorthand
|
||||||
|
- No manual dark: color overrides
|
||||||
|
- Use cn() for conditional classes
|
||||||
|
- No manual z-index on overlay components
|
||||||
|
- Use shimmer / scroll-fade utilities, not custom animations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Semantic colors
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="bg-blue-500 text-white">
|
||||||
|
<p className="text-gray-600">Secondary text</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="bg-primary text-primary-foreground">
|
||||||
|
<p className="text-muted-foreground">Secondary text</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## No raw color values for status/state indicators
|
||||||
|
|
||||||
|
For positive, negative, or status indicators, use Badge variants, semantic tokens like `text-destructive`, or define custom CSS variables — don't reach for raw Tailwind colors.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<span className="text-emerald-600">+20.1%</span>
|
||||||
|
<span className="text-green-500">Active</span>
|
||||||
|
<span className="text-red-600">-3.2%</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Badge variant="secondary">+20.1%</Badge>
|
||||||
|
<Badge>Active</Badge>
|
||||||
|
<span className="text-destructive">-3.2%</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
If you need a success/positive color that doesn't exist as a semantic token, use a Badge variant or ask the user about adding a custom CSS variable to the theme (see [customization.md](../customization.md)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Built-in variants first
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button className="border border-input bg-transparent hover:bg-accent">
|
||||||
|
Click me
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button variant="outline">Click me</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## className for layout only
|
||||||
|
|
||||||
|
Use `className` for layout (e.g. `max-w-md`, `mx-auto`, `mt-4`), **not** for overriding component colors or typography. To change colors, use semantic tokens, built-in variants, or CSS variables.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Card className="bg-blue-100 text-blue-900 font-bold">
|
||||||
|
<CardContent>Dashboard</CardContent>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Card className="max-w-md mx-auto">
|
||||||
|
<CardContent>Dashboard</CardContent>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
To customize a component's appearance, prefer these approaches in order:
|
||||||
|
1. **Built-in variants** — `variant="outline"`, `variant="destructive"`, etc.
|
||||||
|
2. **Semantic color tokens** — `bg-primary`, `text-muted-foreground`.
|
||||||
|
3. **CSS variables** — define custom colors in the global CSS file (see [customization.md](../customization.md)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## No space-x-* / space-y-*
|
||||||
|
|
||||||
|
Use `gap-*` instead. `space-y-4` → `flex flex-col gap-4`. `space-x-2` → `flex gap-2`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Input />
|
||||||
|
<Input />
|
||||||
|
<Button>Submit</Button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prefer size-* over w-* h-* when equal
|
||||||
|
|
||||||
|
`size-10` not `w-10 h-10`. Applies to icons, avatars, skeletons, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prefer truncate shorthand
|
||||||
|
|
||||||
|
`truncate` not `overflow-hidden text-ellipsis whitespace-nowrap`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## No manual dark: color overrides
|
||||||
|
|
||||||
|
Use semantic tokens — they handle light/dark via CSS variables. `bg-background text-foreground` not `bg-white dark:bg-gray-950`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use cn() for conditional classes
|
||||||
|
|
||||||
|
Use the `cn()` utility from the project for conditional or merged class names. Don't write manual ternaries in className strings.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className={`flex items-center ${isActive ? "bg-primary text-primary-foreground" : "bg-muted"}`}>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
<div className={cn("flex items-center", isActive ? "bg-primary text-primary-foreground" : "bg-muted")}>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## No manual z-index on overlay components
|
||||||
|
|
||||||
|
`Dialog`, `Sheet`, `Drawer`, `AlertDialog`, `DropdownMenu`, `Popover`, `Tooltip`, `HoverCard` handle their own stacking. Never add `z-50` or `z-[999]`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use shimmer / scroll-fade utilities, not custom animations
|
||||||
|
|
||||||
|
For a live "thinking…" or loading-text shimmer, apply the `shimmer` utility. Don't author a custom `@keyframes` or a `bg-clip-text` gradient sweep.
|
||||||
|
|
||||||
|
For scroll-aware edge fading on a scroll container, use `scroll-fade` (and the axis variants `scroll-fade-x` / `scroll-fade-b`). Don't hand-roll mask gradients. The chat components already apply these internally: `Attachment` shimmers its title during upload, and `MessageScrollerViewport` fades its edges.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<span className="animate-pulse bg-gradient-to-r from-muted-foreground/40 via-foreground/70 to-muted-foreground/40 bg-clip-text text-transparent [animation:shimmer_1.6s_infinite]">
|
||||||
|
Thinking…
|
||||||
|
</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<span className="shimmer">Thinking…</span>
|
||||||
|
```
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
feat: initial commit
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ref: refs/heads/master
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[core]
|
||||||
|
repositoryformatversion = 0
|
||||||
|
filemode = false
|
||||||
|
bare = false
|
||||||
|
logallrefupdates = true
|
||||||
|
symlinks = false
|
||||||
|
ignorecase = true
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Unnamed repository; edit this file 'description' to name the repository.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user