This commit is contained in:
2026-06-14 13:19:39 +03:00
parent 4a3db42971
commit 19fff3a92f
152 changed files with 10908 additions and 8276 deletions
+24
View File
@@ -0,0 +1,24 @@
POSTGRES_DB=cymelle
POSTGRES_USER=cymelle
POSTGRES_PASSWORD=cymelle
POSTGRES_PORT=5432
API_PORT=8080
WEB_PORT=3000
VITE_API_BASE_URL=http://localhost:8080/api
APP_CORS_ALLOWED_ORIGINS=http://localhost:3000
SEED_DATA=true
LOW_STOCK_THRESHOLD=10
FARE_BASE_FARE=100.00
FARE_PER_KM_RATE=50.00
FARE_MINIMUM_FARE=150.00
FARE_DEFAULT_SURGE_MULTIPLIER=1.0
FARE_CURRENCY=KES
PAYSTACK_SECRET_KEY=
PAYSTACK_PUBLIC_KEY=
PAYSTACK_CALLBACK_URL=http://localhost:3000/payments/paystack/callback
PAYSTACK_WEBHOOK_SECRET=
PAYSTACK_API_BASE_URL=https://api.paystack.co
+1
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
+55
View File
@@ -0,0 +1,55 @@
# Render deployment
This repository is configured for two Docker-backed Render web services:
- `cymelle-api`: Spring Boot API
- `cymelle-web`: TanStack/Nitro frontend
No PostgreSQL container or Render database is created. Use your Neon database connection string.
## Render setup
1. Create a new Blueprint on Render using `render.yaml`.
2. When Render asks for `DATABASE_URL`, paste your Neon pooled PostgreSQL connection string.
- A Neon URL like `postgresql://user:password@host/db?sslmode=require` is accepted.
- A Neon JDBC URL like `jdbc:postgresql://host/db?user=...&password=...&sslmode=require` is also accepted.
3. Deploy both services.
4. If Render assigns different service URLs than the defaults in `render.yaml`, update:
- `cymelle-api` -> `APP_CORS_ALLOWED_ORIGINS` to the frontend URL.
- `cymelle-web` -> `VITE_API_BASE_URL` to the API URL plus `/api`.
5. Configure Paystack on `cymelle-api`:
- `PAYSTACK_SECRET_KEY`: your Paystack secret key.
- `PAYSTACK_PUBLIC_KEY`: your Paystack public key, kept for operational visibility.
- `PAYSTACK_CALLBACK_URL`: frontend callback URL, for example `https://cymelle-web.onrender.com/payments/paystack/callback`.
- `PAYSTACK_WEBHOOK_SECRET`: optional override for webhook HMAC verification; when omitted, the secret key is used.
## Local Docker builds
Build the API:
```sh
docker build -t cymelle-api ./orders-backend
```
Run the API with Neon:
```sh
docker run --rm -p 8080:8080 \
-e DATABASE_URL='postgresql://user:password@host/db?sslmode=require' \
-e APP_CORS_ALLOWED_ORIGINS='http://localhost:3000' \
cymelle-api
```
Build the frontend:
```sh
docker build -t cymelle-web \
--build-arg VITE_API_BASE_URL=http://localhost:8080/api \
./orders-frontend
```
Run the frontend:
```sh
docker run --rm -p 3000:3000 cymelle-web
```
+37 -1
View File
@@ -1 +1,37 @@
# readme
# Cymelle
## Local Docker Compose
Run the full local development stack with Postgres 18, Spring Boot API, and TanStack/Vite web app:
```sh
cp .env.example .env
docker compose up
```
Services:
- Web: `http://localhost:3000`
- API: `http://localhost:8080/api`
- Swagger UI: `http://localhost:8080/swagger-ui.html`
- Postgres: `localhost:5432`, database/user/password `cymelle`
Paystack checkout requires test keys in `.env`:
```sh
PAYSTACK_SECRET_KEY=sk_test_...
PAYSTACK_PUBLIC_KEY=pk_test_...
PAYSTACK_CALLBACK_URL=http://localhost:3000/payments/paystack/callback
```
Useful commands:
```sh
docker compose logs -f api
docker compose logs -f web
docker compose restart api
docker compose down
docker compose down -v
```
The web service runs Vite from mounted source, so frontend edits reload without rebuilding. Restart `api` after backend code changes. Use `docker compose down -v` only when you want to delete the local Postgres and dependency cache volumes.
+74
View File
@@ -0,0 +1,74 @@
name: cymelle
services:
db:
image: postgres:18-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-cymelle}
POSTGRES_USER: ${POSTGRES_USER:-cymelle}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-cymelle}
ports:
- "${POSTGRES_PORT:-5432}:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U ${POSTGRES_USER:-cymelle} -d ${POSTGRES_DB:-cymelle}",
]
interval: 5s
timeout: 5s
retries: 10
api:
image: eclipse-temurin:17-jdk-jammy
working_dir: /workspace
command: ./gradlew --no-daemon bootRun
environment:
PORT: 8080
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/${POSTGRES_DB:-cymelle}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cymelle}
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-cymelle}
APP_CORS_ALLOWED_ORIGINS: ${APP_CORS_ALLOWED_ORIGINS:-http://localhost:3000}
SEED_DATA: ${SEED_DATA:-true}
LOW_STOCK_THRESHOLD: ${LOW_STOCK_THRESHOLD:-10}
FARE_BASE_FARE: ${FARE_BASE_FARE:-100.00}
FARE_PER_KM_RATE: ${FARE_PER_KM_RATE:-50.00}
FARE_MINIMUM_FARE: ${FARE_MINIMUM_FARE:-150.00}
FARE_DEFAULT_SURGE_MULTIPLIER: ${FARE_DEFAULT_SURGE_MULTIPLIER:-1.0}
FARE_CURRENCY: ${FARE_CURRENCY:-KES}
PAYSTACK_SECRET_KEY: ${PAYSTACK_SECRET_KEY:-}
PAYSTACK_PUBLIC_KEY: ${PAYSTACK_PUBLIC_KEY:-}
PAYSTACK_CALLBACK_URL: ${PAYSTACK_CALLBACK_URL:-http://localhost:3000/payments/paystack/callback}
PAYSTACK_WEBHOOK_SECRET: ${PAYSTACK_WEBHOOK_SECRET:-}
PAYSTACK_API_BASE_URL: ${PAYSTACK_API_BASE_URL:-https://api.paystack.co}
ports:
- "${API_PORT:-8080}:8080"
volumes:
- ./orders-backend:/workspace
- gradle-cache:/root/.gradle
depends_on:
db:
condition: service_healthy
web:
image: node:24-bookworm-slim
working_dir: /app
command: sh -c "corepack enable && pnpm install --frozen-lockfile && pnpm dev --host 0.0.0.0"
environment:
HOST: 0.0.0.0
PORT: 3000
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:8080/api}
ports:
- "${WEB_PORT:-3000}:3000"
volumes:
- ./orders-frontend:/app
- frontend-node-modules:/app/node_modules
depends_on:
- api
volumes:
postgres-data:
gradle-cache:
frontend-node-modules:
+7 -1
View File
@@ -10,4 +10,10 @@ FARE_MINIMUM_FARE=150.00
FARE_DEFAULT_SURGE_MULTIPLIER=1.0
FARE_CURRENCY=KES
SEED_DATA=true
PAYSTACK_SECRET_KEY=
PAYSTACK_PUBLIC_KEY=
PAYSTACK_CALLBACK_URL=http://localhost:3000/payments/paystack/callback
PAYSTACK_WEBHOOK_SECRET=
PAYSTACK_API_BASE_URL=https://api.paystack.co
SEED_DATA=true
@@ -2,6 +2,7 @@ package com.andwati.orders;
import com.andwati.orders.config.FareProperties;
import com.andwati.orders.config.InventoryProperties;
import com.andwati.orders.config.PaystackProperties;
import io.github.cdimascio.dotenv.Dotenv;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@@ -10,7 +11,8 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
@SpringBootApplication
@EnableConfigurationProperties({
FareProperties.class,
InventoryProperties.class
InventoryProperties.class,
PaystackProperties.class
})
public class OrdersBackendApplication {
@@ -0,0 +1,32 @@
package com.andwati.orders.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "paystack")
public record PaystackProperties(
String secretKey,
String publicKey,
String callbackUrl,
String webhookSecret,
String apiBaseUrl
) {
public String resolvedApiBaseUrl() {
return hasText(apiBaseUrl) ? apiBaseUrl : "https://api.paystack.co";
}
public String resolvedWebhookSecret() {
return hasText(webhookSecret) ? webhookSecret : secretKey;
}
public boolean hasSecretKey() {
return hasText(secretKey);
}
public boolean hasCallbackUrl() {
return hasText(callbackUrl);
}
private boolean hasText(String value) {
return value != null && !value.isBlank();
}
}
@@ -14,6 +14,7 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHt
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
@@ -31,6 +32,7 @@ public class SecurityConfig {
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(csrfRequestHandler)
.ignoringRequestMatchers(new AntPathRequestMatcher("/api/payments/paystack/webhook", "POST"))
)
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
@@ -40,6 +42,8 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.POST, "/api/auth/login", "/api/auth/register").permitAll()
.requestMatchers("/api-docs/**", "/swagger-ui.html", "/swagger-ui/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/checkout/quote").permitAll()
.requestMatchers(HttpMethod.POST, "/api/payments/paystack/webhook").permitAll()
.requestMatchers(HttpMethod.POST, "/api/products/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.PUT, "/api/products/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.PATCH, "/api/products/**").hasRole("ADMIN")
@@ -0,0 +1,30 @@
package com.andwati.orders.controller;
import com.andwati.orders.dto.request.CheckoutQuoteRequest;
import com.andwati.orders.dto.response.CheckoutQuoteResponse;
import com.andwati.orders.services.PaymentService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
@Tag(name = "Checkout", description = "Quote ecommerce checkout totals")
@RestController
@RequestMapping("/api/checkout")
public class CheckoutController {
private final PaymentService paymentService;
public CheckoutController(PaymentService paymentService) {
this.paymentService = paymentService;
}
@Operation(
summary = "Quote checkout totals",
description = "Calculates product subtotal, optional delivery ride fare, and grand total from current backend data."
)
@PostMapping("/quote")
public CheckoutQuoteResponse quote(@Valid @RequestBody CheckoutQuoteRequest request) {
return paymentService.quote(request);
}
}
@@ -0,0 +1,51 @@
package com.andwati.orders.controller;
import com.andwati.orders.dto.request.PaystackInitializeRequest;
import com.andwati.orders.dto.response.PaystackInitializeResponse;
import com.andwati.orders.dto.response.PaystackVerifyResponse;
import com.andwati.orders.services.PaymentService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
@Tag(name = "Paystack Payments", description = "Initialize and verify Paystack checkout payments")
@RestController
@RequestMapping("/api/payments/paystack")
public class PaystackPaymentController {
private final PaymentService paymentService;
public PaystackPaymentController(PaymentService paymentService) {
this.paymentService = paymentService;
}
@Operation(
summary = "Initialize Paystack checkout",
description = "Creates pending order, optional ride, payment record, and returns a Paystack authorization URL."
)
@PostMapping("/initialize")
public PaystackInitializeResponse initialize(@Valid @RequestBody PaystackInitializeRequest request) {
return paymentService.initialize(request);
}
@Operation(
summary = "Verify Paystack payment",
description = "Verifies the Paystack transaction and marks the payment paid only when reference, amount, and currency match."
)
@PostMapping("/verify/{reference}")
public PaystackVerifyResponse verify(@PathVariable String reference) {
return paymentService.verify(reference);
}
@Operation(summary = "Receive Paystack webhook events")
@PostMapping("/webhook")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void webhook(
@RequestBody String body,
@RequestHeader(name = "x-paystack-signature", required = false) String signature
) {
paymentService.handleWebhook(body, signature);
}
}
@@ -0,0 +1,16 @@
package com.andwati.orders.dto.request;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Size;
import java.util.List;
public record CheckoutQuoteRequest(
@Valid
@Size(min = 1, message = "items must contain at least one item")
List<CreateOrderItemRequest> items,
@Valid
CreateOrderRideRequest deliveryRide
) {
}
@@ -0,0 +1,19 @@
package com.andwati.orders.dto.request;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Size;
import java.util.List;
public record PaystackInitializeRequest(
@Valid
@Size(min = 1, message = "items must contain at least one item")
List<CreateOrderItemRequest> items,
@Valid
CreateOrderRideRequest deliveryRide
) {
public CreateOrderRequest toCreateOrderRequest() {
return new CreateOrderRequest(items, deliveryRide);
}
}
@@ -0,0 +1,13 @@
package com.andwati.orders.dto.response;
import java.math.BigDecimal;
import java.time.Instant;
public record CheckoutQuoteResponse(
BigDecimal productSubtotal,
BigDecimal rideFare,
BigDecimal grandTotal,
String currency,
Instant expiresAt
) {
}
@@ -0,0 +1,18 @@
package com.andwati.orders.dto.response;
import com.andwati.orders.model.PaymentStatus;
import java.math.BigDecimal;
import java.util.UUID;
public record PaystackInitializeResponse(
String authorizationUrl,
String accessCode,
String reference,
UUID orderId,
UUID rideId,
BigDecimal amount,
String currency,
PaymentStatus status
) {
}
@@ -0,0 +1,17 @@
package com.andwati.orders.dto.response;
import com.andwati.orders.model.PaymentStatus;
import java.math.BigDecimal;
import java.util.UUID;
public record PaystackVerifyResponse(
String reference,
PaymentStatus status,
UUID orderId,
UUID rideId,
BigDecimal amount,
String currency,
String message
) {
}
@@ -0,0 +1,250 @@
package com.andwati.orders.model;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(
name = "payments",
uniqueConstraints = {
@UniqueConstraint(name = "uk_payments_reference", columnNames = "reference")
}
)
public class Payment {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@OneToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "order_id", nullable = false)
private Order order;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "ride_id")
private Ride ride;
@Column(nullable = false, length = 80)
private String reference;
@Column(nullable = false, precision = 12, scale = 2)
private BigDecimal productSubtotal;
@Column(nullable = false, precision = 12, scale = 2)
private BigDecimal rideFare;
@Column(nullable = false, precision = 12, scale = 2)
private BigDecimal amountMajor;
@Column(nullable = false)
private long amountSubunits;
@Column(nullable = false, length = 3)
private String currency;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 30)
private PaymentStatus status = PaymentStatus.PENDING;
@Column(length = 512)
private String authorizationUrl;
@Column(length = 120)
private String accessCode;
@Lob
private String rawVerificationPayload;
@Column(nullable = false)
private Instant createdAt;
private Instant paidAt;
private Instant updatedAt;
@PrePersist
public void prePersist() {
Instant now = Instant.now();
if (createdAt == null) {
createdAt = now;
}
if (updatedAt == null) {
updatedAt = now;
}
if (status == null) {
status = PaymentStatus.PENDING;
}
}
@PreUpdate
public void preUpdate() {
updatedAt = Instant.now();
}
public void markPaid(String rawPayload) {
if (status == PaymentStatus.PAID) {
this.rawVerificationPayload = rawPayload;
return;
}
this.status = PaymentStatus.PAID;
this.paidAt = Instant.now();
this.rawVerificationPayload = rawPayload;
}
public void markFailed(PaymentStatus status, String rawPayload) {
if (this.status == PaymentStatus.PAID) {
return;
}
this.status = status;
this.rawVerificationPayload = rawPayload;
}
public UUID getId() {
return id;
}
public Payment setId(UUID id) {
this.id = id;
return this;
}
public Order getOrder() {
return order;
}
public Payment setOrder(Order order) {
this.order = order;
return this;
}
public Ride getRide() {
return ride;
}
public Payment setRide(Ride ride) {
this.ride = ride;
return this;
}
public String getReference() {
return reference;
}
public Payment setReference(String reference) {
this.reference = reference;
return this;
}
public BigDecimal getProductSubtotal() {
return productSubtotal;
}
public Payment setProductSubtotal(BigDecimal productSubtotal) {
this.productSubtotal = productSubtotal;
return this;
}
public BigDecimal getRideFare() {
return rideFare;
}
public Payment setRideFare(BigDecimal rideFare) {
this.rideFare = rideFare;
return this;
}
public BigDecimal getAmountMajor() {
return amountMajor;
}
public Payment setAmountMajor(BigDecimal amountMajor) {
this.amountMajor = amountMajor;
return this;
}
public long getAmountSubunits() {
return amountSubunits;
}
public Payment setAmountSubunits(long amountSubunits) {
this.amountSubunits = amountSubunits;
return this;
}
public String getCurrency() {
return currency;
}
public Payment setCurrency(String currency) {
this.currency = currency;
return this;
}
public PaymentStatus getStatus() {
return status;
}
public Payment setStatus(PaymentStatus status) {
this.status = status;
return this;
}
public String getAuthorizationUrl() {
return authorizationUrl;
}
public Payment setAuthorizationUrl(String authorizationUrl) {
this.authorizationUrl = authorizationUrl;
return this;
}
public String getAccessCode() {
return accessCode;
}
public Payment setAccessCode(String accessCode) {
this.accessCode = accessCode;
return this;
}
public String getRawVerificationPayload() {
return rawVerificationPayload;
}
public Payment setRawVerificationPayload(String rawVerificationPayload) {
this.rawVerificationPayload = rawVerificationPayload;
return this;
}
public Instant getCreatedAt() {
return createdAt;
}
public Payment setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
return this;
}
public Instant getPaidAt() {
return paidAt;
}
public Payment setPaidAt(Instant paidAt) {
this.paidAt = paidAt;
return this;
}
public Instant getUpdatedAt() {
return updatedAt;
}
public Payment setUpdatedAt(Instant updatedAt) {
this.updatedAt = updatedAt;
return this;
}
}
@@ -0,0 +1,9 @@
package com.andwati.orders.model;
public enum PaymentStatus {
PENDING,
PAID,
FAILED,
ABANDONED,
REFUNDED
}
@@ -0,0 +1,14 @@
package com.andwati.orders.repository;
import com.andwati.orders.model.Payment;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface PaymentRepository extends JpaRepository<Payment, UUID> {
@EntityGraph(attributePaths = {"order", "order.customer", "ride"})
Optional<Payment> findByReference(String reference);
}
@@ -57,6 +57,18 @@ public class OrderService {
@Transactional
public OrderResponse placeOrder(CreateOrderRequest request) {
AppUser customer = appUserService.getCurrentUser();
PendingOrderResult result = createPendingOrder(customer, request, null, null);
return orderMapper.toResponse(result.order());
}
@Transactional
public PendingOrderResult createPendingOrder(
AppUser customer,
CreateOrderRequest request,
java.math.BigDecimal rideFare,
String rideCurrency
) {
if (customer.getRole() != Role.CUSTOMER) {
throw new IllegalArgumentException("Only customers can place orders");
}
@@ -103,6 +115,7 @@ public class OrderService {
order.recalculateTotal();
Order savedOrder = orderRepository.save(order);
Ride savedRide = null;
if (request.deliveryRide() != null) {
Ride ride = new Ride()
@@ -111,12 +124,14 @@ public class OrderService {
.setPickupLocation(request.deliveryRide().pickupLocation().trim())
.setDropoffLocation(request.deliveryRide().dropoffLocation().trim())
.setDistanceKm(request.deliveryRide().distanceKm())
.setFareAmount(rideFare)
.setCurrency(rideCurrency == null ? "KES" : rideCurrency)
.setStatus(RideStatus.REQUESTED);
rideRepository.save(ride);
savedRide = rideRepository.save(ride);
}
return orderMapper.toResponse(savedOrder);
return new PendingOrderResult(savedOrder, savedRide);
}
@Transactional
@@ -0,0 +1,412 @@
package com.andwati.orders.services;
import com.andwati.orders.config.PaystackProperties;
import com.andwati.orders.dto.request.CheckoutQuoteRequest;
import com.andwati.orders.dto.request.CreateOrderRequest;
import com.andwati.orders.dto.request.PaystackInitializeRequest;
import com.andwati.orders.dto.response.CheckoutQuoteResponse;
import com.andwati.orders.dto.response.FareCalculationResponse;
import com.andwati.orders.dto.response.PaystackInitializeResponse;
import com.andwati.orders.dto.response.PaystackVerifyResponse;
import com.andwati.orders.exception.InsufficientStockException;
import com.andwati.orders.exception.InventoryNotFoundException;
import com.andwati.orders.exception.ProductNotFoundException;
import com.andwati.orders.model.*;
import com.andwati.orders.repository.InventoryItemRepository;
import com.andwati.orders.repository.PaymentRepository;
import com.andwati.orders.repository.ProductRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class PaymentService {
private static final String DEFAULT_CURRENCY = "KES";
private static final int QUOTE_TTL_MINUTES = 15;
private final ProductRepository productRepository;
private final InventoryItemRepository inventoryItemRepository;
private final PaymentRepository paymentRepository;
private final FareCalculationService fareCalculationService;
private final OrderService orderService;
private final AppUserService appUserService;
private final PaystackClient paystackClient;
private final PaystackProperties paystackProperties;
private final ObjectMapper objectMapper;
public PaymentService(
ProductRepository productRepository,
InventoryItemRepository inventoryItemRepository,
PaymentRepository paymentRepository,
FareCalculationService fareCalculationService,
OrderService orderService,
AppUserService appUserService,
PaystackClient paystackClient,
PaystackProperties paystackProperties,
ObjectMapper objectMapper
) {
this.productRepository = productRepository;
this.inventoryItemRepository = inventoryItemRepository;
this.paymentRepository = paymentRepository;
this.fareCalculationService = fareCalculationService;
this.orderService = orderService;
this.appUserService = appUserService;
this.paystackClient = paystackClient;
this.paystackProperties = paystackProperties;
this.objectMapper = objectMapper;
}
@Transactional(readOnly = true)
public CheckoutQuoteResponse quote(CheckoutQuoteRequest request) {
CheckoutQuote quote = calculateQuote(request.items(), request.deliveryRide());
return toQuoteResponse(quote);
}
@Transactional
public PaystackInitializeResponse initialize(PaystackInitializeRequest request) {
AppUser customer = appUserService.getCurrentUser();
if (customer.getRole() != Role.CUSTOMER) {
throw new IllegalArgumentException("Only customers can checkout");
}
CheckoutQuote quote = calculateQuote(request.items(), request.deliveryRide());
PendingOrderResult pendingOrder = orderService.createPendingOrder(
customer,
request.toCreateOrderRequest(),
quote.rideFare(),
quote.currency()
);
String reference = generateReference();
Payment payment = new Payment()
.setOrder(pendingOrder.order())
.setRide(pendingOrder.ride())
.setReference(reference)
.setProductSubtotal(quote.productSubtotal())
.setRideFare(quote.rideFare())
.setAmountMajor(quote.grandTotal())
.setAmountSubunits(toSubunits(quote.grandTotal()))
.setCurrency(quote.currency())
.setStatus(PaymentStatus.PENDING);
paymentRepository.save(payment);
JsonNode paystackData = paystackClient.initializeTransaction(
customer.getUsername(),
payment.getAmountSubunits(),
payment.getCurrency(),
payment.getReference(),
buildMetadata(customer, payment)
);
payment
.setAuthorizationUrl(paystackData.path("authorization_url").asText())
.setAccessCode(paystackData.path("access_code").asText());
return toInitializeResponse(payment);
}
@Transactional
public PaystackVerifyResponse verify(String reference) {
Payment payment = getPayment(reference);
assertCanAccess(payment);
return verifyPayment(payment);
}
@Transactional
public void handleWebhook(String body, String signature) {
verifyWebhookSignature(body, signature);
JsonNode event;
try {
event = objectMapper.readTree(body);
} catch (JsonProcessingException exception) {
throw new IllegalArgumentException("Webhook payload is not valid JSON");
}
if (!"charge.success".equals(event.path("event").asText())) {
return;
}
String reference = event.path("data").path("reference").asText(null);
if (reference == null || reference.isBlank()) {
return;
}
paymentRepository.findByReference(reference).ifPresent(this::verifyPayment);
}
private PaystackVerifyResponse verifyPayment(Payment payment) {
JsonNode verification = paystackClient.verifyTransaction(payment.getReference());
JsonNode data = verification.path("data");
String rawPayload = toJson(verification);
boolean success = "success".equals(data.path("status").asText());
boolean matchesReference = payment.getReference().equals(data.path("reference").asText());
boolean matchesAmount = payment.getAmountSubunits() == data.path("amount").asLong();
boolean matchesCurrency = payment.getCurrency().equalsIgnoreCase(data.path("currency").asText());
if (success && matchesReference && matchesAmount && matchesCurrency) {
payment.markPaid(rawPayload);
return toVerifyResponse(payment, "Payment verified");
}
PaymentStatus nextStatus = mapPaystackStatus(data.path("status").asText());
payment.markFailed(nextStatus, rawPayload);
if (!success) {
return toVerifyResponse(payment, data.path("gateway_response").asText("Payment was not successful"));
}
throw new IllegalArgumentException("Paystack verification did not match the stored payment");
}
private CheckoutQuote calculateQuote(
java.util.List<com.andwati.orders.dto.request.CreateOrderItemRequest> items,
com.andwati.orders.dto.request.CreateOrderRideRequest deliveryRide
) {
if (items == null || items.isEmpty()) {
throw new IllegalArgumentException("items must contain at least one item");
}
Map<UUID, Integer> requestedQuantities = items.stream()
.collect(Collectors.toMap(
com.andwati.orders.dto.request.CreateOrderItemRequest::productId,
com.andwati.orders.dto.request.CreateOrderItemRequest::quantity,
Integer::sum,
LinkedHashMap::new
));
Map<UUID, Product> productsById = productRepository.findByIdIn(requestedQuantities.keySet())
.stream()
.collect(Collectors.toMap(Product::getId, Function.identity()));
for (UUID productId : requestedQuantities.keySet()) {
Product product = productsById.get(productId);
if (product == null) {
throw new ProductNotFoundException(productId);
}
if (!product.isActive()) {
throw new IllegalArgumentException("Product " + product.getName() + " is not active");
}
}
Map<UUID, InventoryItem> inventoryByProductId = inventoryItemRepository
.findByProduct_IdIn(requestedQuantities.keySet())
.stream()
.collect(Collectors.toMap(
inventory -> inventory.getProduct().getId(),
Function.identity()
));
BigDecimal productSubtotal = BigDecimal.ZERO;
String currency = null;
for (Map.Entry<UUID, Integer> entry : requestedQuantities.entrySet()) {
UUID productId = entry.getKey();
int quantity = entry.getValue();
if (quantity < 1) {
throw new IllegalArgumentException("quantity must be at least 1");
}
Product product = productsById.get(productId);
InventoryItem inventory = inventoryByProductId.get(productId);
if (inventory == null) {
throw new InventoryNotFoundException(productId);
}
if (!inventory.hasEnoughStock(quantity)) {
throw new InsufficientStockException(
productId,
product.getName(),
quantity,
inventory.getAvailableQuantity()
);
}
if (currency == null) {
currency = product.getCurrency();
} else if (!currency.equals(product.getCurrency())) {
throw new IllegalArgumentException("Cart items must use one currency");
}
productSubtotal = productSubtotal.add(product.getPrice().multiply(BigDecimal.valueOf(quantity)));
}
BigDecimal rideFare = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
if (deliveryRide != null) {
FareCalculationResponse fare = fareCalculationService.calculateFare(deliveryRide.distanceKm(), null);
if (currency != null && !currency.equals(fare.currency())) {
throw new IllegalArgumentException("Ride fare currency must match cart currency");
}
currency = fare.currency();
rideFare = fare.calculatedFare();
}
String resolvedCurrency = currency == null ? DEFAULT_CURRENCY : currency;
BigDecimal normalizedSubtotal = productSubtotal.setScale(2, RoundingMode.HALF_UP);
BigDecimal normalizedRideFare = rideFare.setScale(2, RoundingMode.HALF_UP);
return new CheckoutQuote(
normalizedSubtotal,
normalizedRideFare,
normalizedSubtotal.add(normalizedRideFare).setScale(2, RoundingMode.HALF_UP),
resolvedCurrency
);
}
private CheckoutQuoteResponse toQuoteResponse(CheckoutQuote quote) {
return new CheckoutQuoteResponse(
quote.productSubtotal(),
quote.rideFare(),
quote.grandTotal(),
quote.currency(),
Instant.now().plusSeconds(QUOTE_TTL_MINUTES * 60L)
);
}
private PaystackInitializeResponse toInitializeResponse(Payment payment) {
return new PaystackInitializeResponse(
payment.getAuthorizationUrl(),
payment.getAccessCode(),
payment.getReference(),
payment.getOrder().getId(),
payment.getRide() == null ? null : payment.getRide().getId(),
payment.getAmountMajor(),
payment.getCurrency(),
payment.getStatus()
);
}
private PaystackVerifyResponse toVerifyResponse(Payment payment, String message) {
return new PaystackVerifyResponse(
payment.getReference(),
payment.getStatus(),
payment.getOrder().getId(),
payment.getRide() == null ? null : payment.getRide().getId(),
payment.getAmountMajor(),
payment.getCurrency(),
message
);
}
private Map<String, Object> buildMetadata(AppUser customer, Payment payment) {
Map<String, Object> metadata = new LinkedHashMap<>();
metadata.put("orderId", payment.getOrder().getId().toString());
metadata.put("rideId", payment.getRide() == null ? null : payment.getRide().getId().toString());
metadata.put("userId", customer.getId().toString());
metadata.put("productSubtotal", payment.getProductSubtotal());
metadata.put("rideFare", payment.getRideFare());
metadata.put("grandTotal", payment.getAmountMajor());
metadata.put("currency", payment.getCurrency());
return metadata;
}
private long toSubunits(BigDecimal amount) {
return amount.setScale(2, RoundingMode.HALF_UP)
.movePointRight(2)
.longValueExact();
}
private String generateReference() {
return "CYM-" + UUID.randomUUID();
}
private Payment getPayment(String reference) {
return paymentRepository.findByReference(reference)
.orElseThrow(() -> new IllegalArgumentException("Payment reference was not found"));
}
private void assertCanAccess(Payment payment) {
AppUser currentUser = appUserService.getCurrentUser();
if (currentUser.getRole() == Role.ADMIN) {
return;
}
AppUser customer = payment.getOrder().getCustomer();
if (currentUser.getRole() == Role.CUSTOMER
&& customer != null
&& currentUser.getId().equals(customer.getId())) {
return;
}
throw new IllegalArgumentException("You cannot access this payment");
}
private PaymentStatus mapPaystackStatus(String status) {
return switch (status) {
case "abandoned" -> PaymentStatus.ABANDONED;
case "reversed" -> PaymentStatus.REFUNDED;
default -> PaymentStatus.FAILED;
};
}
private void verifyWebhookSignature(String body, String signature) {
String secret = paystackProperties.resolvedWebhookSecret();
if (secret == null || secret.isBlank()) {
throw new IllegalStateException("PAYSTACK_WEBHOOK_SECRET or PAYSTACK_SECRET_KEY is not configured");
}
if (signature == null || signature.isBlank()) {
throw new IllegalArgumentException("Paystack signature is required");
}
try {
Mac mac = Mac.getInstance("HmacSHA512");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA512"));
String expected = HexFormat.of().formatHex(mac.doFinal(body.getBytes(StandardCharsets.UTF_8)));
if (!constantTimeEquals(expected, signature)) {
throw new IllegalArgumentException("Invalid Paystack signature");
}
} catch (IllegalArgumentException exception) {
throw exception;
} catch (Exception exception) {
throw new IllegalStateException("Could not verify Paystack signature", exception);
}
}
private boolean constantTimeEquals(String expected, String actual) {
byte[] expectedBytes = expected.getBytes(StandardCharsets.UTF_8);
byte[] actualBytes = actual.getBytes(StandardCharsets.UTF_8);
if (expectedBytes.length != actualBytes.length) {
return false;
}
int result = 0;
for (int index = 0; index < expectedBytes.length; index++) {
result |= expectedBytes[index] ^ actualBytes[index];
}
return result == 0;
}
private String toJson(JsonNode node) {
try {
return objectMapper.writeValueAsString(node);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("Could not serialize Paystack verification payload", exception);
}
}
private record CheckoutQuote(
BigDecimal productSubtotal,
BigDecimal rideFare,
BigDecimal grandTotal,
String currency
) {
}
}
@@ -0,0 +1,80 @@
package com.andwati.orders.services;
import com.andwati.orders.config.PaystackProperties;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.util.LinkedHashMap;
import java.util.Map;
@Component
public class PaystackClient {
private final PaystackProperties paystackProperties;
private final RestClient restClient;
public PaystackClient(PaystackProperties paystackProperties, RestClient.Builder restClientBuilder) {
this.paystackProperties = paystackProperties;
this.restClient = restClientBuilder
.baseUrl(paystackProperties.resolvedApiBaseUrl())
.build();
}
public JsonNode initializeTransaction(
String email,
long amountSubunits,
String currency,
String reference,
Map<String, Object> metadata
) {
assertConfigured();
Map<String, Object> body = new LinkedHashMap<>();
body.put("email", email);
body.put("amount", String.valueOf(amountSubunits));
body.put("currency", currency);
body.put("reference", reference);
body.put("metadata", metadata);
if (paystackProperties.hasCallbackUrl()) {
body.put("callback_url", paystackProperties.callbackUrl());
}
JsonNode response = restClient.post()
.uri("/transaction/initialize")
.headers(headers -> headers.setBearerAuth(paystackProperties.secretKey()))
.body(body)
.retrieve()
.body(JsonNode.class);
assertSuccessful(response, "Paystack transaction initialization failed");
return response.path("data");
}
public JsonNode verifyTransaction(String reference) {
assertConfigured();
JsonNode response = restClient.get()
.uri("/transaction/verify/{reference}", reference)
.headers(headers -> headers.setBearerAuth(paystackProperties.secretKey()))
.retrieve()
.body(JsonNode.class);
assertSuccessful(response, "Paystack transaction verification failed");
return response;
}
private void assertConfigured() {
if (!paystackProperties.hasSecretKey()) {
throw new IllegalStateException("PAYSTACK_SECRET_KEY is not configured");
}
}
private void assertSuccessful(JsonNode response, String fallbackMessage) {
if (response == null || !response.path("status").asBoolean(false)) {
String message = response == null ? fallbackMessage : response.path("message").asText(fallbackMessage);
throw new IllegalStateException(message);
}
}
}
@@ -0,0 +1,7 @@
package com.andwati.orders.services;
import com.andwati.orders.model.Order;
import com.andwati.orders.model.Ride;
public record PendingOrderResult(Order order, Ride ride) {
}
@@ -20,6 +20,12 @@ fare.minimum-fare=${FARE_MINIMUM_FARE:150.00}
fare.default-surge-multiplier=${FARE_DEFAULT_SURGE_MULTIPLIER:1.0}
fare.currency=${FARE_CURRENCY:KES}
paystack.secret-key=${PAYSTACK_SECRET_KEY:}
paystack.public-key=${PAYSTACK_PUBLIC_KEY:}
paystack.callback-url=${PAYSTACK_CALLBACK_URL:}
paystack.webhook-secret=${PAYSTACK_WEBHOOK_SECRET:}
paystack.api-base-url=${PAYSTACK_API_BASE_URL:https://api.paystack.co}
app.seed-data=${SEED_DATA:true}
app.cors.allowed-origins=${APP_CORS_ALLOWED_ORIGINS:http://localhost:3000}
springdoc.api-docs.path=/api-docs
+33 -33
View File
@@ -1,35 +1,35 @@
{
"files.watcherExclude": {
"**/routeTree.gen.ts": true
},
"search.exclude": {
"**/routeTree.gen.ts": true
},
"files.readonlyInclude": {
"**/routeTree.gen.ts": true
},
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[jsonc]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[css]": {
"editor.defaultFormatter": "biomejs.biome"
},
"editor.codeActionsOnSave": {
"source.organizeImports.biome": "explicit"
}
"files.watcherExclude": {
"**/routeTree.gen.ts": true
},
"search.exclude": {
"**/routeTree.gen.ts": true
},
"files.readonlyInclude": {
"**/routeTree.gen.ts": true
},
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[jsonc]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[css]": {
"editor.defaultFormatter": "biomejs.biome"
},
"editor.codeActionsOnSave": {
"source.organizeImports.biome": "explicit"
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
FROM node:22-bookworm-slim AS build
FROM node:24-bookworm-slim AS build
WORKDIR /app
@@ -14,7 +14,7 @@ ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
RUN pnpm build
FROM node:22-bookworm-slim
FROM node:24-bookworm-slim
WORKDIR /app
+1 -2
View File
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
@@ -33,4 +33,3 @@
}
}
}
+13 -13
View File
@@ -1,25 +1,25 @@
import {apiRequest, clearRequestSecurity} from "#/api/client";
import type {AuthUser, LoginRequest, RegisterRequest} from "#/types/auth";
import { apiRequest, clearRequestSecurity } from "#/api/client";
import type { AuthUser, LoginRequest, RegisterRequest } from "#/types/auth";
export function getCurrentUser(signal?: AbortSignal) {
return apiRequest<AuthUser>("/auth/me", {signal});
return apiRequest<AuthUser>("/auth/me", { signal });
}
export function login(request: LoginRequest) {
return apiRequest<AuthUser>("/auth/login", {
method: "POST",
body: request,
});
return apiRequest<AuthUser>("/auth/login", {
method: "POST",
body: request,
});
}
export function register(request: RegisterRequest) {
return apiRequest<AuthUser>("/auth/register", {
method: "POST",
body: request,
});
return apiRequest<AuthUser>("/auth/register", {
method: "POST",
body: request,
});
}
export async function logout() {
await apiRequest<void>("/auth/logout", {method: "POST"});
clearRequestSecurity();
await apiRequest<void>("/auth/logout", { method: "POST" });
clearRequestSecurity();
}
+71 -69
View File
@@ -1,110 +1,112 @@
import type {ApiErrorResponse} from "#/types/api";
import type { ApiErrorResponse } from "#/types/api";
const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080/api";
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080/api";
let csrfToken: string | null = null;
let csrfHeaderName = "X-XSRF-TOKEN";
export class ApiError extends Error {
status: number;
code: string;
details: unknown;
status: number;
code: string;
details: unknown;
constructor(status: number, error: ApiErrorResponse) {
super(error.message || "Request failed");
this.name = "ApiError";
this.status = status;
this.code = error.error;
this.details = error.details;
}
constructor(status: number, error: ApiErrorResponse) {
super(error.message || "Request failed");
this.name = "ApiError";
this.status = status;
this.code = error.error;
this.details = error.details;
}
}
type RequestOptions = {
method?: "GET" | "POST" | "DELETE" | "PUT" | "PATCH";
body?: unknown;
signal?: AbortSignal;
method?: "GET" | "POST" | "DELETE" | "PUT" | "PATCH";
body?: unknown;
signal?: AbortSignal;
};
type CsrfResponse = {
headerName: string;
parameterName: string;
token: string;
headerName: string;
parameterName: string;
token: string;
};
async function loadCsrfToken() {
const response = await fetch(`${API_BASE_URL}/auth/csrf`, {
credentials: "include",
});
const response = await fetch(`${API_BASE_URL}/auth/csrf`, {
credentials: "include",
});
if (!response.ok) {
throw new Error("Could not initialize request security");
}
if (!response.ok) {
throw new Error("Could not initialize request security");
}
const data = (await response.json()) as CsrfResponse;
csrfToken = data.token;
csrfHeaderName = data.headerName;
const data = (await response.json()) as CsrfResponse;
csrfToken = data.token;
csrfHeaderName = data.headerName;
}
function isMutating(method: string) {
return method !== "GET";
return method !== "GET";
}
export async function apiRequest<T>(
path: string,
options: RequestOptions = {},
path: string,
options: RequestOptions = {},
): Promise<T> {
const method = options.method ?? "GET";
const method = options.method ?? "GET";
if (isMutating(method) && !csrfToken) {
await loadCsrfToken();
}
if (isMutating(method) && !csrfToken) {
await loadCsrfToken();
}
const response = await fetch(`${API_BASE_URL}${path}`, {
method,
credentials: "include",
headers: {
"Content-Type": "application/json",
...(isMutating(method) && csrfToken ? {[csrfHeaderName]: csrfToken} : {}),
},
body: options.body ? JSON.stringify(options.body) : undefined,
signal: options.signal,
});
const response = await fetch(`${API_BASE_URL}${path}`, {
method,
credentials: "include",
headers: {
"Content-Type": "application/json",
...(isMutating(method) && csrfToken
? { [csrfHeaderName]: csrfToken }
: {}),
},
body: options.body ? JSON.stringify(options.body) : undefined,
signal: options.signal,
});
if (!response.ok) {
let errorBody: ApiErrorResponse;
if (!response.ok) {
let errorBody: ApiErrorResponse;
try {
errorBody = await response.json();
} catch {
errorBody = {
error: "REQUEST_FAILED",
message: `Request failed with status ${response.status}`,
details: null,
timestamp: new Date().toISOString(),
path,
};
}
try {
errorBody = await response.json();
} catch {
errorBody = {
error: "REQUEST_FAILED",
message: `Request failed with status ${response.status}`,
details: null,
timestamp: new Date().toISOString(),
path,
};
}
throw new ApiError(response.status, errorBody);
}
throw new ApiError(response.status, errorBody);
}
if (response.status === 204) {
return undefined as T;
}
if (response.status === 204) {
return undefined as T;
}
const text = await response.text();
if (!text) {
return undefined as T;
}
const text = await response.text();
if (!text) {
return undefined as T;
}
return JSON.parse(text) as T;
return JSON.parse(text) as T;
}
export function clearRequestSecurity() {
csrfToken = null;
csrfToken = null;
}
export async function initializeRequestSecurity() {
await loadCsrfToken();
await loadCsrfToken();
}
+15 -15
View File
@@ -1,20 +1,20 @@
import {apiRequest} from "#/api/client";
import type {FareCalculationResponse} from "#/types/fare";
import { apiRequest } from "#/api/client";
import type { FareCalculationResponse } from "#/types/fare";
export function calculateFare(
distanceKm: number,
surgeMultiplier?: number,
signal?: AbortSignal,
distanceKm: number,
surgeMultiplier?: number,
signal?: AbortSignal,
) {
const params = new URLSearchParams();
params.set("distanceKm", String(distanceKm));
const params = new URLSearchParams();
params.set("distanceKm", String(distanceKm));
if (surgeMultiplier !== undefined) {
params.set("surgeMultiplier", String(surgeMultiplier));
}
if (surgeMultiplier !== undefined) {
params.set("surgeMultiplier", String(surgeMultiplier));
}
return apiRequest<FareCalculationResponse>(
`/fare/calculate?${params.toString()}`,
{signal},
);
}
return apiRequest<FareCalculationResponse>(
`/fare/calculate?${params.toString()}`,
{ signal },
);
}
+17 -17
View File
@@ -1,24 +1,24 @@
import {apiRequest} from "#/api/client";
import type {InventoryResponse, LowStockInventoryResponse,} from "#/types/inventory";
import { apiRequest } from "#/api/client";
import type {
InventoryResponse,
LowStockInventoryResponse,
} from "#/types/inventory";
export function getInventory(signal?: AbortSignal) {
return apiRequest<InventoryResponse>("/inventory", {signal});
return apiRequest<InventoryResponse>("/inventory", { signal });
}
export function getLowStockInventory(
threshold?: number,
signal?: AbortSignal,
) {
const params = new URLSearchParams();
export function getLowStockInventory(threshold?: number, signal?: AbortSignal) {
const params = new URLSearchParams();
if (threshold !== undefined) {
params.set("threshold", String(threshold));
}
if (threshold !== undefined) {
params.set("threshold", String(threshold));
}
const query = params.toString();
const query = params.toString();
return apiRequest<LowStockInventoryResponse>(
`/inventory/low-stock${query ? `?${query}` : ""}`,
{signal},
);
}
return apiRequest<LowStockInventoryResponse>(
`/inventory/low-stock${query ? `?${query}` : ""}`,
{ signal },
);
}
+42 -39
View File
@@ -1,62 +1,65 @@
import {apiRequest} from "#/api/client";
import type {PageResponse} from "#/types/api";
import { apiRequest } from "#/api/client";
import type { PageResponse } from "#/types/api";
import type {
CancelOrderResponse,
CreateOrderRequest,
Order,
OrderFilters,
OrderStatus,
OrderSummary,
CancelOrderResponse,
CreateOrderRequest,
Order,
OrderFilters,
OrderStatus,
OrderSummary,
} from "#/types/order";
export function getOrders(
filters: OrderFilters,
page = 0,
size = 20,
signal?: AbortSignal,
filters: OrderFilters,
page = 0,
size = 20,
signal?: AbortSignal,
) {
const params = new URLSearchParams();
const params = new URLSearchParams();
if (filters.status) {
params.set("status", filters.status);
}
if (filters.status) {
params.set("status", filters.status);
}
if (filters.from) {
params.set("from", filters.from);
}
if (filters.from) {
params.set("from", filters.from);
}
if (filters.to) {
params.set("to", filters.to);
}
if (filters.to) {
params.set("to", filters.to);
}
params.set("page", String(page));
params.set("size", String(size));
params.set("page", String(page));
params.set("size", String(size));
return apiRequest<PageResponse<OrderSummary>>(`/orders?${params.toString()}`, {
signal,
});
return apiRequest<PageResponse<OrderSummary>>(
`/orders?${params.toString()}`,
{
signal,
},
);
}
export function getOrder(id: string, signal?: AbortSignal) {
return apiRequest<Order>(`/orders/${id}`, {signal});
return apiRequest<Order>(`/orders/${id}`, { signal });
}
export function createOrder(request: CreateOrderRequest) {
return apiRequest<Order>("/orders", {
method: "POST",
body: request,
});
return apiRequest<Order>("/orders", {
method: "POST",
body: request,
});
}
export function cancelOrder(id: string) {
return apiRequest<CancelOrderResponse>(`/orders/${id}`, {
method: "DELETE",
});
return apiRequest<CancelOrderResponse>(`/orders/${id}`, {
method: "DELETE",
});
}
export function updateOrderStatus(id: string, status: OrderStatus) {
return apiRequest<Order>(`/orders/${id}/status`, {
method: "PATCH",
body: {status},
});
return apiRequest<Order>(`/orders/${id}/status`, {
method: "PATCH",
body: { status },
});
}
+38
View File
@@ -0,0 +1,38 @@
import { apiRequest } from "#/api/client";
import type {
CheckoutQuote,
CheckoutQuoteRequest,
PaystackInitializeRequest,
PaystackInitializeResponse,
PaystackVerifyResponse,
} from "#/types/payment";
export function getCheckoutQuote(
request: CheckoutQuoteRequest,
signal?: AbortSignal,
) {
return apiRequest<CheckoutQuote>("/checkout/quote", {
method: "POST",
body: request,
signal,
});
}
export function initializePaystackPayment(request: PaystackInitializeRequest) {
return apiRequest<PaystackInitializeResponse>(
"/payments/paystack/initialize",
{
method: "POST",
body: request,
},
);
}
export function verifyPaystackPayment(reference: string) {
return apiRequest<PaystackVerifyResponse>(
`/payments/paystack/verify/${encodeURIComponent(reference)}`,
{
method: "POST",
},
);
}
+13 -13
View File
@@ -1,28 +1,28 @@
import {apiRequest} from "#/api/client";
import type {Product, ProductRequest} from "#/types/product";
import { apiRequest } from "#/api/client";
import type { Product, ProductRequest } from "#/types/product";
export function getProducts(signal?: AbortSignal) {
return apiRequest<Product[]>("/products", {signal});
return apiRequest<Product[]>("/products", { signal });
}
export function getProduct(id: string, signal?: AbortSignal) {
return apiRequest<Product>(`/products/${id}`, {signal});
return apiRequest<Product>(`/products/${id}`, { signal });
}
export function createProduct(request: ProductRequest) {
return apiRequest<Product>("/products", {
method: "POST",
body: request,
});
return apiRequest<Product>("/products", {
method: "POST",
body: request,
});
}
export function updateProduct(id: string, request: ProductRequest) {
return apiRequest<Product>(`/products/${id}`, {
method: "PUT",
body: request,
});
return apiRequest<Product>(`/products/${id}`, {
method: "PUT",
body: request,
});
}
export function deactivateProduct(id: string) {
return apiRequest<Product>(`/products/${id}`, {method: "DELETE"});
return apiRequest<Product>(`/products/${id}`, { method: "DELETE" });
}
+14 -14
View File
@@ -1,32 +1,32 @@
import {apiRequest} from "#/api/client";
import type {CreateRideRequest, Ride, RideStatus} from "#/types/ride";
import { apiRequest } from "#/api/client";
import type { CreateRideRequest, Ride, RideStatus } from "#/types/ride";
export function getRides(signal?: AbortSignal) {
return apiRequest<Ride[]>("/rides", {signal});
return apiRequest<Ride[]>("/rides", { signal });
}
export function requestRide(request: CreateRideRequest) {
return apiRequest<Ride>("/rides", {
method: "POST",
body: request,
});
return apiRequest<Ride>("/rides", {
method: "POST",
body: request,
});
}
export function acceptRide(id: string) {
return apiRequest<Ride>(`/rides/${id}/accept`, {method: "POST"});
return apiRequest<Ride>(`/rides/${id}/accept`, { method: "POST" });
}
export function completeRide(id: string) {
return apiRequest<Ride>(`/rides/${id}/complete`, {method: "POST"});
return apiRequest<Ride>(`/rides/${id}/complete`, { method: "POST" });
}
export function cancelRide(id: string) {
return apiRequest<Ride>(`/rides/${id}`, {method: "DELETE"});
return apiRequest<Ride>(`/rides/${id}`, { method: "DELETE" });
}
export function updateRideStatus(id: string, status: RideStatus) {
return apiRequest<Ride>(`/rides/${id}/status`, {
method: "PATCH",
body: {status},
});
return apiRequest<Ride>(`/rides/${id}/status`, {
method: "PATCH",
body: { status },
});
}
+14 -14
View File
@@ -1,32 +1,32 @@
import {apiRequest} from "#/api/client";
import type {AdminUserRequest, UserSummary} from "#/types/user";
import { apiRequest } from "#/api/client";
import type { AdminUserRequest, UserSummary } from "#/types/user";
export function getUsers(signal?: AbortSignal) {
return apiRequest<UserSummary[]>("/users", {signal});
return apiRequest<UserSummary[]>("/users", { signal });
}
export function getCustomers(signal?: AbortSignal) {
return apiRequest<UserSummary[]>("/users/customers", {signal});
return apiRequest<UserSummary[]>("/users/customers", { signal });
}
export function getDrivers(signal?: AbortSignal) {
return apiRequest<UserSummary[]>("/users/drivers", {signal});
return apiRequest<UserSummary[]>("/users/drivers", { signal });
}
export function createUser(request: AdminUserRequest) {
return apiRequest<UserSummary>("/users", {
method: "POST",
body: request,
});
return apiRequest<UserSummary>("/users", {
method: "POST",
body: request,
});
}
export function updateUser(id: string, request: AdminUserRequest) {
return apiRequest<UserSummary>(`/users/${id}`, {
method: "PUT",
body: request,
});
return apiRequest<UserSummary>(`/users/${id}`, {
method: "PUT",
body: request,
});
}
export function disableUser(id: string) {
return apiRequest<UserSummary>(`/users/${id}`, {method: "DELETE"});
return apiRequest<UserSummary>(`/users/${id}`, { method: "DELETE" });
}
+190 -187
View File
@@ -1,199 +1,202 @@
"use client"
"use client";
import * as React from "react"
import {
Boxes,
Car,
GalleryVerticalEnd,
LayoutDashboard,
ReceiptText,
ShoppingCart,
Store,
Truck,
Users,
} from "lucide-react"
Boxes,
Car,
GalleryVerticalEnd,
LayoutDashboard,
ReceiptText,
ShoppingCart,
Store,
Truck,
Users,
} from "lucide-react";
import type * as React from "react";
import { NavMain } from "#/components/nav-main.tsx"
import { NavUser } from "#/components/nav-user.tsx"
import { TeamSwitcher } from "#/components/team-switcher.tsx"
import { NavMain } from "#/components/nav-main.tsx";
import { NavUser } from "#/components/nav-user.tsx";
import { TeamSwitcher } from "#/components/team-switcher.tsx";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarRail,
} from "#/components/ui/sidebar.tsx"
import type { AuthUser, Role } from "#/types/auth.ts"
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarRail,
} from "#/components/ui/sidebar.tsx";
import type { AuthUser, Role } from "#/types/auth.ts";
const roleLabels: Record<Role, string> = {
ADMIN: "Admin workspace",
CUSTOMER: "Customer store",
DRIVER: "Driver workspace",
}
ADMIN: "Admin workspace",
CUSTOMER: "Customer store",
DRIVER: "Driver workspace",
};
const navMainByRole: Record<Role, React.ComponentProps<typeof NavMain>["items"]> = {
ADMIN: [
{
title: "Overview",
url: "/admin",
icon: LayoutDashboard,
isActive: true,
items: [
{ title: "Operations", url: "/admin" },
{ title: "Inventory health", url: "/admin/inventory" },
],
},
{
title: "Products",
url: "/admin/products",
icon: Boxes,
items: [
{ title: "Product catalog", url: "/admin/products" },
{ title: "Create product", url: "/admin/products/new" },
],
},
{
title: "Orders",
url: "/admin/orders",
icon: ReceiptText,
items: [
{ title: "Pending orders", url: "/admin/orders/pending" },
{ title: "Shipment status", url: "/admin/orders" },
],
},
{
title: "Rides",
url: "/admin/rides",
icon: Car,
items: [
{ title: "Ride requests", url: "/admin/rides/requested" },
{ title: "Driver assignments", url: "/admin/rides/accepted" },
],
},
{
title: "Users",
url: "/admin/users",
icon: Users,
items: [
{ title: "All users", url: "/admin/users" },
{ title: "Customers", url: "/admin/customers" },
{ title: "Drivers", url: "/admin/drivers" },
],
},
],
CUSTOMER: [
{
title: "Overview",
url: "/",
icon: LayoutDashboard,
isActive: true,
items: [
{ title: "Dashboard", url: "/" },
{ title: "Order history", url: "/orders" },
],
},
{
title: "Storefront",
url: "/products",
icon: Store,
items: [
{ title: "Product listing", url: "/products" },
{ title: "Featured inventory", url: "/products/featured" },
],
},
{
title: "Cart",
url: "/cart",
icon: ShoppingCart,
items: [
{ title: "Review cart", url: "/cart" },
{ title: "Checkout", url: "/cart" },
],
},
{
title: "Orders",
url: "/orders",
icon: ReceiptText,
items: [
{ title: "Order history", url: "/orders" },
{ title: "Delivery status", url: "/orders/status" },
],
},
{
title: "Rides",
url: "/rides",
icon: Car,
items: [
{ title: "Request a ride", url: "/rides/new" },
{ title: "Ride history", url: "/rides" },
],
},
],
DRIVER: [
{
title: "Overview",
url: "/",
icon: LayoutDashboard,
isActive: true,
items: [
{ title: "Dashboard", url: "/" },
{ title: "Completed rides", url: "/driver/rides/completed" },
],
},
{
title: "Ride board",
url: "/driver/rides",
icon: Car,
items: [
{ title: "Requested rides", url: "/driver/rides/requested" },
{ title: "Accepted rides", url: "/driver/rides/accepted" },
],
},
{
title: "Routes",
url: "/driver/rides/accepted",
icon: Truck,
items: [
{ title: "Active route", url: "/driver/rides/accepted" },
{ title: "Completed rides", url: "/driver/rides/completed" },
],
},
],
}
const navMainByRole: Record<
Role,
React.ComponentProps<typeof NavMain>["items"]
> = {
ADMIN: [
{
title: "Overview",
url: "/admin",
icon: LayoutDashboard,
isActive: true,
items: [
{ title: "Operations", url: "/admin" },
{ title: "Inventory health", url: "/admin/inventory" },
],
},
{
title: "Products",
url: "/admin/products",
icon: Boxes,
items: [
{ title: "Product catalog", url: "/admin/products" },
{ title: "Create product", url: "/admin/products/new" },
],
},
{
title: "Orders",
url: "/admin/orders",
icon: ReceiptText,
items: [
{ title: "Pending orders", url: "/admin/orders/pending" },
{ title: "Shipment status", url: "/admin/orders" },
],
},
{
title: "Rides",
url: "/admin/rides",
icon: Car,
items: [
{ title: "Ride requests", url: "/admin/rides/requested" },
{ title: "Driver assignments", url: "/admin/rides/accepted" },
],
},
{
title: "Users",
url: "/admin/users",
icon: Users,
items: [
{ title: "All users", url: "/admin/users" },
{ title: "Customers", url: "/admin/customers" },
{ title: "Drivers", url: "/admin/drivers" },
],
},
],
CUSTOMER: [
{
title: "Overview",
url: "/",
icon: LayoutDashboard,
isActive: true,
items: [
{ title: "Dashboard", url: "/" },
{ title: "Order history", url: "/orders" },
],
},
{
title: "Storefront",
url: "/products",
icon: Store,
items: [
{ title: "Product listing", url: "/products" },
{ title: "Featured inventory", url: "/products/featured" },
],
},
{
title: "Cart",
url: "/cart",
icon: ShoppingCart,
items: [
{ title: "Review cart", url: "/cart" },
{ title: "Checkout", url: "/cart" },
],
},
{
title: "Orders",
url: "/orders",
icon: ReceiptText,
items: [
{ title: "Order history", url: "/orders" },
{ title: "Delivery status", url: "/orders/status" },
],
},
{
title: "Rides",
url: "/rides",
icon: Car,
items: [
{ title: "Request a ride", url: "/rides/new" },
{ title: "Ride history", url: "/rides" },
],
},
],
DRIVER: [
{
title: "Overview",
url: "/",
icon: LayoutDashboard,
isActive: true,
items: [
{ title: "Dashboard", url: "/" },
{ title: "Completed rides", url: "/driver/rides/completed" },
],
},
{
title: "Ride board",
url: "/driver/rides",
icon: Car,
items: [
{ title: "Requested rides", url: "/driver/rides/requested" },
{ title: "Accepted rides", url: "/driver/rides/accepted" },
],
},
{
title: "Routes",
url: "/driver/rides/accepted",
icon: Truck,
items: [
{ title: "Active route", url: "/driver/rides/accepted" },
{ title: "Completed rides", url: "/driver/rides/completed" },
],
},
],
};
type AppSidebarProps = React.ComponentProps<typeof Sidebar> & {
user: AuthUser
onLogout?: () => void
}
user: AuthUser;
onLogout?: () => void;
};
export function AppSidebar({ user, onLogout, ...props }: AppSidebarProps) {
return (
<Sidebar collapsible="icon" {...props}>
<SidebarHeader>
<TeamSwitcher
teams={[
{
name: "Cymelle",
logo: GalleryVerticalEnd,
plan: roleLabels[user.role],
},
]}
/>
</SidebarHeader>
<SidebarContent>
<NavMain items={navMainByRole[user.role]} />
</SidebarContent>
<SidebarFooter>
<NavUser
user={{
name: user.displayName,
email: user.username,
avatar: "",
}}
onLogout={onLogout}
/>
</SidebarFooter>
<SidebarRail />
</Sidebar>
)
return (
<Sidebar collapsible="icon" {...props}>
<SidebarHeader>
<TeamSwitcher
teams={[
{
name: "Cymelle",
logo: GalleryVerticalEnd,
plan: roleLabels[user.role],
},
]}
/>
</SidebarHeader>
<SidebarContent>
<NavMain items={navMainByRole[user.role]} />
</SidebarContent>
<SidebarFooter>
<NavUser
user={{
name: user.displayName,
email: user.username,
avatar: "",
}}
onLogout={onLogout}
/>
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
}
+79 -66
View File
@@ -1,81 +1,94 @@
import {AppSidebar} from "#/components/app-sidebar";
import {SiteHeader} from "#/components/site-header";
import {StoreHome} from "#/components/store/StoreHome";
import {Card, CardContent, CardHeader} from "#/components/ui/card";
import {Skeleton} from "#/components/ui/skeleton";
import {SidebarInset, SidebarProvider} from "#/components/ui/sidebar";
import {useAuth} from "#/hooks/useAuth";
import type {Role} from "#/types/auth";
import type {CSSProperties, ReactNode} from "react";
import type { CSSProperties, ReactNode } from "react";
import { AppSidebar } from "#/components/app-sidebar";
import { SiteHeader } from "#/components/site-header";
import { StoreHome } from "#/components/store/StoreHome";
import { Card, CardContent, CardHeader } from "#/components/ui/card";
import { SidebarInset, SidebarProvider } from "#/components/ui/sidebar";
import { Skeleton } from "#/components/ui/skeleton";
import { useAuth } from "#/hooks/useAuth";
import type { Role } from "#/types/auth";
type AppShellProps = {
title: string;
allowedRoles?: Role[];
showCart?: boolean;
children: ReactNode;
title: string;
allowedRoles?: Role[];
showCart?: boolean;
children: ReactNode;
};
export function AppShell({title, allowedRoles, showCart = false, children}: AppShellProps) {
const {user, isLoading, logout} = useAuth();
export function AppShell({
title,
allowedRoles,
showCart = false,
children,
}: AppShellProps) {
const { user, isLoading, logout } = useAuth();
if (isLoading && !user) {
return <WorkspaceSkeleton/>;
}
if (isLoading && !user) {
return <WorkspaceSkeleton />;
}
if (!user) {
return <StoreHome/>;
}
if (!user) {
return <StoreHome />;
}
const allowed = !allowedRoles || allowedRoles.includes(user.role);
const allowed = !allowedRoles || allowedRoles.includes(user.role);
return (
<SidebarProvider
style={{
"--sidebar-width": "18rem",
"--header-height": "3.5rem",
} as CSSProperties}
>
<AppSidebar user={user} onLogout={logout} variant="inset"/>
<SidebarInset>
<SiteHeader title={title} onLogout={logout} showCart={showCart || user.role === "CUSTOMER"}/>
<main className="@container/main flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">
{allowed ? children : <AccessDenied/>}
</main>
</SidebarInset>
</SidebarProvider>
);
return (
<SidebarProvider
style={
{
"--sidebar-width": "18rem",
"--header-height": "3.5rem",
} as CSSProperties
}
>
<AppSidebar user={user} onLogout={logout} variant="inset" />
<SidebarInset>
<SiteHeader
title={title}
onLogout={logout}
showCart={showCart || user.role === "CUSTOMER"}
/>
<main className="@container/main flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">
{allowed ? children : <AccessDenied />}
</main>
</SidebarInset>
</SidebarProvider>
);
}
function WorkspaceSkeleton() {
return (
<main className="min-h-screen bg-background p-4 md:p-6">
<div className="mx-auto max-w-6xl space-y-4">
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-48"/>
<Skeleton className="h-9 w-32"/>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Skeleton className="h-28"/>
<Skeleton className="h-28"/>
<Skeleton className="h-28"/>
</div>
<Skeleton className="h-80"/>
</div>
</main>
);
return (
<main className="min-h-screen bg-background p-4 md:p-6">
<div className="mx-auto max-w-6xl space-y-4">
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-9 w-32" />
</div>
<div className="grid gap-4 md:grid-cols-3">
<Skeleton className="h-28" />
<Skeleton className="h-28" />
<Skeleton className="h-28" />
</div>
<Skeleton className="h-80" />
</div>
</main>
);
}
function AccessDenied() {
return (
<Card>
<CardHeader>
<h2 className="text-lg font-semibold">This page is not available for your role</h2>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Switch to the correct account type to view this workspace.
</p>
</CardContent>
</Card>
);
return (
<Card>
<CardHeader>
<h2 className="text-lg font-semibold">
This page is not available for your role
</h2>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Switch to the correct account type to view this workspace.
</p>
</CardContent>
</Card>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,291 +1,287 @@
"use client"
"use client";
import * as React from "react"
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
import * as React from "react";
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "#/components/ui/card.tsx";
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "#/components/ui/chart.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "#/components/ui/select.tsx";
import { ToggleGroup, ToggleGroupItem } from "#/components/ui/toggle-group.tsx";
import { useIsMobile } from "#/hooks/use-mobile.ts";
import { useIsMobile } from "#/hooks/use-mobile.ts"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "#/components/ui/card.tsx"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "#/components/ui/chart.tsx"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "#/components/ui/select.tsx"
import {
ToggleGroup,
ToggleGroupItem,
} from "#/components/ui/toggle-group.tsx"
export const description = "An interactive area chart"
export const description = "An interactive area chart";
const chartData = [
{ date: "2024-04-01", desktop: 222, mobile: 150 },
{ date: "2024-04-02", desktop: 97, mobile: 180 },
{ date: "2024-04-03", desktop: 167, mobile: 120 },
{ date: "2024-04-04", desktop: 242, mobile: 260 },
{ date: "2024-04-05", desktop: 373, mobile: 290 },
{ date: "2024-04-06", desktop: 301, mobile: 340 },
{ date: "2024-04-07", desktop: 245, mobile: 180 },
{ date: "2024-04-08", desktop: 409, mobile: 320 },
{ date: "2024-04-09", desktop: 59, mobile: 110 },
{ date: "2024-04-10", desktop: 261, mobile: 190 },
{ date: "2024-04-11", desktop: 327, mobile: 350 },
{ date: "2024-04-12", desktop: 292, mobile: 210 },
{ date: "2024-04-13", desktop: 342, mobile: 380 },
{ date: "2024-04-14", desktop: 137, mobile: 220 },
{ date: "2024-04-15", desktop: 120, mobile: 170 },
{ date: "2024-04-16", desktop: 138, mobile: 190 },
{ date: "2024-04-17", desktop: 446, mobile: 360 },
{ date: "2024-04-18", desktop: 364, mobile: 410 },
{ date: "2024-04-19", desktop: 243, mobile: 180 },
{ date: "2024-04-20", desktop: 89, mobile: 150 },
{ date: "2024-04-21", desktop: 137, mobile: 200 },
{ date: "2024-04-22", desktop: 224, mobile: 170 },
{ date: "2024-04-23", desktop: 138, mobile: 230 },
{ date: "2024-04-24", desktop: 387, mobile: 290 },
{ date: "2024-04-25", desktop: 215, mobile: 250 },
{ date: "2024-04-26", desktop: 75, mobile: 130 },
{ date: "2024-04-27", desktop: 383, mobile: 420 },
{ date: "2024-04-28", desktop: 122, mobile: 180 },
{ date: "2024-04-29", desktop: 315, mobile: 240 },
{ date: "2024-04-30", desktop: 454, mobile: 380 },
{ date: "2024-05-01", desktop: 165, mobile: 220 },
{ date: "2024-05-02", desktop: 293, mobile: 310 },
{ date: "2024-05-03", desktop: 247, mobile: 190 },
{ date: "2024-05-04", desktop: 385, mobile: 420 },
{ date: "2024-05-05", desktop: 481, mobile: 390 },
{ date: "2024-05-06", desktop: 498, mobile: 520 },
{ date: "2024-05-07", desktop: 388, mobile: 300 },
{ date: "2024-05-08", desktop: 149, mobile: 210 },
{ date: "2024-05-09", desktop: 227, mobile: 180 },
{ date: "2024-05-10", desktop: 293, mobile: 330 },
{ date: "2024-05-11", desktop: 335, mobile: 270 },
{ date: "2024-05-12", desktop: 197, mobile: 240 },
{ date: "2024-05-13", desktop: 197, mobile: 160 },
{ date: "2024-05-14", desktop: 448, mobile: 490 },
{ date: "2024-05-15", desktop: 473, mobile: 380 },
{ date: "2024-05-16", desktop: 338, mobile: 400 },
{ date: "2024-05-17", desktop: 499, mobile: 420 },
{ date: "2024-05-18", desktop: 315, mobile: 350 },
{ date: "2024-05-19", desktop: 235, mobile: 180 },
{ date: "2024-05-20", desktop: 177, mobile: 230 },
{ date: "2024-05-21", desktop: 82, mobile: 140 },
{ date: "2024-05-22", desktop: 81, mobile: 120 },
{ date: "2024-05-23", desktop: 252, mobile: 290 },
{ date: "2024-05-24", desktop: 294, mobile: 220 },
{ date: "2024-05-25", desktop: 201, mobile: 250 },
{ date: "2024-05-26", desktop: 213, mobile: 170 },
{ date: "2024-05-27", desktop: 420, mobile: 460 },
{ date: "2024-05-28", desktop: 233, mobile: 190 },
{ date: "2024-05-29", desktop: 78, mobile: 130 },
{ date: "2024-05-30", desktop: 340, mobile: 280 },
{ date: "2024-05-31", desktop: 178, mobile: 230 },
{ date: "2024-06-01", desktop: 178, mobile: 200 },
{ date: "2024-06-02", desktop: 470, mobile: 410 },
{ date: "2024-06-03", desktop: 103, mobile: 160 },
{ date: "2024-06-04", desktop: 439, mobile: 380 },
{ date: "2024-06-05", desktop: 88, mobile: 140 },
{ date: "2024-06-06", desktop: 294, mobile: 250 },
{ date: "2024-06-07", desktop: 323, mobile: 370 },
{ date: "2024-06-08", desktop: 385, mobile: 320 },
{ date: "2024-06-09", desktop: 438, mobile: 480 },
{ date: "2024-06-10", desktop: 155, mobile: 200 },
{ date: "2024-06-11", desktop: 92, mobile: 150 },
{ date: "2024-06-12", desktop: 492, mobile: 420 },
{ date: "2024-06-13", desktop: 81, mobile: 130 },
{ date: "2024-06-14", desktop: 426, mobile: 380 },
{ date: "2024-06-15", desktop: 307, mobile: 350 },
{ date: "2024-06-16", desktop: 371, mobile: 310 },
{ date: "2024-06-17", desktop: 475, mobile: 520 },
{ date: "2024-06-18", desktop: 107, mobile: 170 },
{ date: "2024-06-19", desktop: 341, mobile: 290 },
{ date: "2024-06-20", desktop: 408, mobile: 450 },
{ date: "2024-06-21", desktop: 169, mobile: 210 },
{ date: "2024-06-22", desktop: 317, mobile: 270 },
{ date: "2024-06-23", desktop: 480, mobile: 530 },
{ date: "2024-06-24", desktop: 132, mobile: 180 },
{ date: "2024-06-25", desktop: 141, mobile: 190 },
{ date: "2024-06-26", desktop: 434, mobile: 380 },
{ date: "2024-06-27", desktop: 448, mobile: 490 },
{ date: "2024-06-28", desktop: 149, mobile: 200 },
{ date: "2024-06-29", desktop: 103, mobile: 160 },
{ date: "2024-06-30", desktop: 446, mobile: 400 },
]
{ date: "2024-04-01", desktop: 222, mobile: 150 },
{ date: "2024-04-02", desktop: 97, mobile: 180 },
{ date: "2024-04-03", desktop: 167, mobile: 120 },
{ date: "2024-04-04", desktop: 242, mobile: 260 },
{ date: "2024-04-05", desktop: 373, mobile: 290 },
{ date: "2024-04-06", desktop: 301, mobile: 340 },
{ date: "2024-04-07", desktop: 245, mobile: 180 },
{ date: "2024-04-08", desktop: 409, mobile: 320 },
{ date: "2024-04-09", desktop: 59, mobile: 110 },
{ date: "2024-04-10", desktop: 261, mobile: 190 },
{ date: "2024-04-11", desktop: 327, mobile: 350 },
{ date: "2024-04-12", desktop: 292, mobile: 210 },
{ date: "2024-04-13", desktop: 342, mobile: 380 },
{ date: "2024-04-14", desktop: 137, mobile: 220 },
{ date: "2024-04-15", desktop: 120, mobile: 170 },
{ date: "2024-04-16", desktop: 138, mobile: 190 },
{ date: "2024-04-17", desktop: 446, mobile: 360 },
{ date: "2024-04-18", desktop: 364, mobile: 410 },
{ date: "2024-04-19", desktop: 243, mobile: 180 },
{ date: "2024-04-20", desktop: 89, mobile: 150 },
{ date: "2024-04-21", desktop: 137, mobile: 200 },
{ date: "2024-04-22", desktop: 224, mobile: 170 },
{ date: "2024-04-23", desktop: 138, mobile: 230 },
{ date: "2024-04-24", desktop: 387, mobile: 290 },
{ date: "2024-04-25", desktop: 215, mobile: 250 },
{ date: "2024-04-26", desktop: 75, mobile: 130 },
{ date: "2024-04-27", desktop: 383, mobile: 420 },
{ date: "2024-04-28", desktop: 122, mobile: 180 },
{ date: "2024-04-29", desktop: 315, mobile: 240 },
{ date: "2024-04-30", desktop: 454, mobile: 380 },
{ date: "2024-05-01", desktop: 165, mobile: 220 },
{ date: "2024-05-02", desktop: 293, mobile: 310 },
{ date: "2024-05-03", desktop: 247, mobile: 190 },
{ date: "2024-05-04", desktop: 385, mobile: 420 },
{ date: "2024-05-05", desktop: 481, mobile: 390 },
{ date: "2024-05-06", desktop: 498, mobile: 520 },
{ date: "2024-05-07", desktop: 388, mobile: 300 },
{ date: "2024-05-08", desktop: 149, mobile: 210 },
{ date: "2024-05-09", desktop: 227, mobile: 180 },
{ date: "2024-05-10", desktop: 293, mobile: 330 },
{ date: "2024-05-11", desktop: 335, mobile: 270 },
{ date: "2024-05-12", desktop: 197, mobile: 240 },
{ date: "2024-05-13", desktop: 197, mobile: 160 },
{ date: "2024-05-14", desktop: 448, mobile: 490 },
{ date: "2024-05-15", desktop: 473, mobile: 380 },
{ date: "2024-05-16", desktop: 338, mobile: 400 },
{ date: "2024-05-17", desktop: 499, mobile: 420 },
{ date: "2024-05-18", desktop: 315, mobile: 350 },
{ date: "2024-05-19", desktop: 235, mobile: 180 },
{ date: "2024-05-20", desktop: 177, mobile: 230 },
{ date: "2024-05-21", desktop: 82, mobile: 140 },
{ date: "2024-05-22", desktop: 81, mobile: 120 },
{ date: "2024-05-23", desktop: 252, mobile: 290 },
{ date: "2024-05-24", desktop: 294, mobile: 220 },
{ date: "2024-05-25", desktop: 201, mobile: 250 },
{ date: "2024-05-26", desktop: 213, mobile: 170 },
{ date: "2024-05-27", desktop: 420, mobile: 460 },
{ date: "2024-05-28", desktop: 233, mobile: 190 },
{ date: "2024-05-29", desktop: 78, mobile: 130 },
{ date: "2024-05-30", desktop: 340, mobile: 280 },
{ date: "2024-05-31", desktop: 178, mobile: 230 },
{ date: "2024-06-01", desktop: 178, mobile: 200 },
{ date: "2024-06-02", desktop: 470, mobile: 410 },
{ date: "2024-06-03", desktop: 103, mobile: 160 },
{ date: "2024-06-04", desktop: 439, mobile: 380 },
{ date: "2024-06-05", desktop: 88, mobile: 140 },
{ date: "2024-06-06", desktop: 294, mobile: 250 },
{ date: "2024-06-07", desktop: 323, mobile: 370 },
{ date: "2024-06-08", desktop: 385, mobile: 320 },
{ date: "2024-06-09", desktop: 438, mobile: 480 },
{ date: "2024-06-10", desktop: 155, mobile: 200 },
{ date: "2024-06-11", desktop: 92, mobile: 150 },
{ date: "2024-06-12", desktop: 492, mobile: 420 },
{ date: "2024-06-13", desktop: 81, mobile: 130 },
{ date: "2024-06-14", desktop: 426, mobile: 380 },
{ date: "2024-06-15", desktop: 307, mobile: 350 },
{ date: "2024-06-16", desktop: 371, mobile: 310 },
{ date: "2024-06-17", desktop: 475, mobile: 520 },
{ date: "2024-06-18", desktop: 107, mobile: 170 },
{ date: "2024-06-19", desktop: 341, mobile: 290 },
{ date: "2024-06-20", desktop: 408, mobile: 450 },
{ date: "2024-06-21", desktop: 169, mobile: 210 },
{ date: "2024-06-22", desktop: 317, mobile: 270 },
{ date: "2024-06-23", desktop: 480, mobile: 530 },
{ date: "2024-06-24", desktop: 132, mobile: 180 },
{ date: "2024-06-25", desktop: 141, mobile: 190 },
{ date: "2024-06-26", desktop: 434, mobile: 380 },
{ date: "2024-06-27", desktop: 448, mobile: 490 },
{ date: "2024-06-28", desktop: 149, mobile: 200 },
{ date: "2024-06-29", desktop: 103, mobile: 160 },
{ date: "2024-06-30", desktop: 446, mobile: 400 },
];
const chartConfig = {
visitors: {
label: "Visitors",
},
desktop: {
label: "Desktop",
color: "var(--primary)",
},
mobile: {
label: "Mobile",
color: "var(--primary)",
},
} satisfies ChartConfig
visitors: {
label: "Visitors",
},
desktop: {
label: "Desktop",
color: "var(--primary)",
},
mobile: {
label: "Mobile",
color: "var(--primary)",
},
} satisfies ChartConfig;
export function ChartAreaInteractive() {
const isMobile = useIsMobile()
const [timeRange, setTimeRange] = React.useState("90d")
const isMobile = useIsMobile();
const [timeRange, setTimeRange] = React.useState("90d");
React.useEffect(() => {
if (isMobile) {
setTimeRange("7d")
}
}, [isMobile])
React.useEffect(() => {
if (isMobile) {
setTimeRange("7d");
}
}, [isMobile]);
const filteredData = chartData.filter((item) => {
const date = new Date(item.date)
const referenceDate = new Date("2024-06-30")
let daysToSubtract = 90
if (timeRange === "30d") {
daysToSubtract = 30
} else if (timeRange === "7d") {
daysToSubtract = 7
}
const startDate = new Date(referenceDate)
startDate.setDate(startDate.getDate() - daysToSubtract)
return date >= startDate
})
const filteredData = chartData.filter((item) => {
const date = new Date(item.date);
const referenceDate = new Date("2024-06-30");
let daysToSubtract = 90;
if (timeRange === "30d") {
daysToSubtract = 30;
} else if (timeRange === "7d") {
daysToSubtract = 7;
}
const startDate = new Date(referenceDate);
startDate.setDate(startDate.getDate() - daysToSubtract);
return date >= startDate;
});
return (
<Card className="@container/card">
<CardHeader>
<CardTitle>Total Visitors</CardTitle>
<CardDescription>
<span className="hidden @[540px]/card:block">
Total for the last 3 months
</span>
<span className="@[540px]/card:hidden">Last 3 months</span>
</CardDescription>
<CardAction>
<ToggleGroup
type="single"
value={timeRange}
onValueChange={setTimeRange}
variant="outline"
className="hidden *:data-[slot=toggle-group-item]:px-4! @[767px]/card:flex"
>
<ToggleGroupItem value="90d">Last 3 months</ToggleGroupItem>
<ToggleGroupItem value="30d">Last 30 days</ToggleGroupItem>
<ToggleGroupItem value="7d">Last 7 days</ToggleGroupItem>
</ToggleGroup>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger
className="flex w-40 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate @[767px]/card:hidden"
size="sm"
aria-label="Select a value"
>
<SelectValue placeholder="Last 3 months" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="90d" className="rounded-lg">
Last 3 months
</SelectItem>
<SelectItem value="30d" className="rounded-lg">
Last 30 days
</SelectItem>
<SelectItem value="7d" className="rounded-lg">
Last 7 days
</SelectItem>
</SelectContent>
</Select>
</CardAction>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<AreaChart data={filteredData}>
<defs>
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-desktop)"
stopOpacity={1.0}
/>
<stop
offset="95%"
stopColor="var(--color-desktop)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient id="fillMobile" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-mobile)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-mobile)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => {
const date = new Date(value)
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
}}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(value) => {
return new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
}}
indicator="dot"
/>
}
/>
<Area
dataKey="mobile"
type="natural"
fill="url(#fillMobile)"
stroke="var(--color-mobile)"
stackId="a"
/>
<Area
dataKey="desktop"
type="natural"
fill="url(#fillDesktop)"
stroke="var(--color-desktop)"
stackId="a"
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
)
return (
<Card className="@container/card">
<CardHeader>
<CardTitle>Total Visitors</CardTitle>
<CardDescription>
<span className="hidden @[540px]/card:block">
Total for the last 3 months
</span>
<span className="@[540px]/card:hidden">Last 3 months</span>
</CardDescription>
<CardAction>
<ToggleGroup
type="single"
value={timeRange}
onValueChange={setTimeRange}
variant="outline"
className="hidden *:data-[slot=toggle-group-item]:px-4! @[767px]/card:flex"
>
<ToggleGroupItem value="90d">Last 3 months</ToggleGroupItem>
<ToggleGroupItem value="30d">Last 30 days</ToggleGroupItem>
<ToggleGroupItem value="7d">Last 7 days</ToggleGroupItem>
</ToggleGroup>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger
className="flex w-40 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate @[767px]/card:hidden"
size="sm"
aria-label="Select a value"
>
<SelectValue placeholder="Last 3 months" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="90d" className="rounded-lg">
Last 3 months
</SelectItem>
<SelectItem value="30d" className="rounded-lg">
Last 30 days
</SelectItem>
<SelectItem value="7d" className="rounded-lg">
Last 7 days
</SelectItem>
</SelectContent>
</Select>
</CardAction>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<AreaChart data={filteredData}>
<defs>
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-desktop)"
stopOpacity={1.0}
/>
<stop
offset="95%"
stopColor="var(--color-desktop)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient id="fillMobile" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-mobile)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-mobile)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => {
const date = new Date(value);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(value) => {
return new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}}
indicator="dot"
/>
}
/>
<Area
dataKey="mobile"
type="natural"
fill="url(#fillMobile)"
stroke="var(--color-mobile)"
stackId="a"
/>
<Area
dataKey="desktop"
type="natural"
fill="url(#fillDesktop)"
stroke="var(--color-desktop)"
stackId="a"
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+93 -93
View File
@@ -1,100 +1,100 @@
import { cn } from "#/lib/utils.ts"
import { Button } from "#/components/ui/button.tsx"
import { Button } from "#/components/ui/button.tsx";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "#/components/ui/card.tsx"
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "#/components/ui/card.tsx";
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "#/components/ui/field.tsx"
import { Input } from "#/components/ui/input.tsx"
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "#/components/ui/field.tsx";
import { Input } from "#/components/ui/input.tsx";
import { cn } from "#/lib/utils.ts";
type LoginFormProps = React.ComponentProps<"div"> & {
username: string
password: string
error?: string | null
pending?: boolean
onUsernameChange: (value: string) => void
onPasswordChange: (value: string) => void
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void
onSignupClick: () => void
}
type LoginFormProps = Omit<React.ComponentProps<"div">, "onSubmit"> & {
username: string;
password: string;
error?: string | null;
pending?: boolean;
onUsernameChange: (value: string) => void;
onPasswordChange: (value: string) => void;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void | Promise<void>;
onSignupClick: () => void;
};
export function LoginForm({
className,
username,
password,
error,
pending = false,
onUsernameChange,
onPasswordChange,
onSubmit,
onSignupClick,
...props
className,
username,
password,
error,
pending = false,
onUsernameChange,
onPasswordChange,
onSubmit,
onSignupClick,
...props
}: LoginFormProps) {
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<CardHeader>
<CardTitle>Sign in to Cymelle</CardTitle>
<CardDescription>
Use your username and password to continue.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="login-username">Username</FieldLabel>
<Input
id="login-username"
type="text"
autoComplete="username"
value={username}
onChange={(event) => onUsernameChange(event.target.value)}
required
/>
</Field>
<Field>
<div className="flex items-center">
<FieldLabel htmlFor="login-password">Password</FieldLabel>
</div>
<Input
id="login-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => onPasswordChange(event.target.value)}
required
/>
</Field>
{error ? <FieldError>{error}</FieldError> : null}
<Field>
<Button type="submit" disabled={pending}>
{pending ? "Signing in..." : "Sign in"}
</Button>
<FieldDescription className="text-center">
Don&apos;t have an account?{" "}
<button
type="button"
className="underline underline-offset-4 hover:text-primary"
onClick={onSignupClick}
>
Sign up
</button>
</FieldDescription>
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
</div>
)
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<CardHeader>
<CardTitle>Sign in to Cymelle</CardTitle>
<CardDescription>
Use your username and password to continue.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="login-username">Username</FieldLabel>
<Input
id="login-username"
type="text"
autoComplete="username"
value={username}
onChange={(event) => onUsernameChange(event.target.value)}
required
/>
</Field>
<Field>
<div className="flex items-center">
<FieldLabel htmlFor="login-password">Password</FieldLabel>
</div>
<Input
id="login-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => onPasswordChange(event.target.value)}
required
/>
</Field>
{error ? <FieldError>{error}</FieldError> : null}
<Field>
<Button type="submit" disabled={pending}>
{pending ? "Signing in..." : "Sign in"}
</Button>
<FieldDescription className="text-center">
Don&apos;t have an account?{" "}
<button
type="button"
className="underline underline-offset-4 hover:text-primary"
onClick={onSignupClick}
>
Sign up
</button>
</FieldDescription>
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
</div>
);
}
@@ -1,92 +1,92 @@
"use client"
"use client";
import {
IconDots,
IconFolder,
IconShare3,
IconTrash,
type Icon,
} from "@tabler/icons-react"
type Icon,
IconDots,
IconFolder,
IconShare3,
IconTrash,
} from "@tabler/icons-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "#/components/ui/dropdown-menu.tsx"
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "#/components/ui/dropdown-menu.tsx";
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "#/components/ui/sidebar.tsx"
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "#/components/ui/sidebar.tsx";
export function NavDocuments({
items,
items,
}: {
items: {
name: string
url: string
icon: Icon
}[]
items: {
name: string;
url: string;
icon: Icon;
}[];
}) {
const { isMobile } = useSidebar()
const { isMobile } = useSidebar();
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Documents</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.name}</span>
</a>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="rounded-sm data-[state=open]:bg-accent"
>
<IconDots />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-24 rounded-lg"
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}
>
<DropdownMenuItem>
<IconFolder />
<span>Open</span>
</DropdownMenuItem>
<DropdownMenuItem>
<IconShare3 />
<span>Share</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<IconTrash />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton className="text-sidebar-foreground/70">
<IconDots className="text-sidebar-foreground/70" />
<span>More</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
)
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Documents</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.name}</span>
</a>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="rounded-sm data-[state=open]:bg-accent"
>
<IconDots />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-24 rounded-lg"
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}
>
<DropdownMenuItem>
<IconFolder />
<span>Open</span>
</DropdownMenuItem>
<DropdownMenuItem>
<IconShare3 />
<span>Share</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<IconTrash />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton className="text-sidebar-foreground/70">
<IconDots className="text-sidebar-foreground/70" />
<span>More</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
);
}
+141 -135
View File
@@ -1,164 +1,170 @@
import { ChevronRight, type LucideIcon } from "lucide-react"
import { Link, useLocation } from "@tanstack/react-router"
import { useEffect, useRef, useState } from "react"
import { Link, useLocation } from "@tanstack/react-router";
import { ChevronRight, type LucideIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "#/components/ui/collapsible.tsx"
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "#/components/ui/collapsible.tsx";
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "#/components/ui/sidebar.tsx"
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "#/components/ui/sidebar.tsx";
const openSectionsStorageKey = "cymelle.sidebar.open-sections"
const openSectionsStorageKey = "cymelle.sidebar.open-sections";
export function NavMain({
items,
items,
}: {
items: {
title: string
url: string
icon?: LucideIcon
isActive?: boolean
items?: {
title: string
url: string
}[]
}[]
items: {
title: string;
url: string;
icon?: LucideIcon;
isActive?: boolean;
items?: {
title: string;
url: string;
}[];
}[];
}) {
const location = useLocation()
const previousPath = useRef<string | null>(null)
const [openSections, setOpenSections] = useState<Set<string>>(() => {
const itemTitles = new Set(items.map((item) => item.title))
const storedSections = readOpenSections().filter((title) => itemTitles.has(title))
const location = useLocation();
const previousPath = useRef<string | null>(null);
const [openSections, setOpenSections] = useState<Set<string>>(() => {
const itemTitles = new Set(items.map((item) => item.title));
const storedSections = readOpenSections().filter((title) =>
itemTitles.has(title),
);
if (storedSections.length > 0) {
return new Set(storedSections)
}
if (storedSections.length > 0) {
return new Set(storedSections);
}
return new Set(
items
.filter((item) => isItemActive(item, location.pathname))
.map((item) => item.title)
)
})
return new Set(
items
.filter((item) => isItemActive(item, location.pathname))
.map((item) => item.title),
);
});
useEffect(() => {
writeOpenSections([...openSections])
}, [openSections])
useEffect(() => {
writeOpenSections([...openSections]);
}, [openSections]);
useEffect(() => {
if (previousPath.current === location.pathname) {
return
}
useEffect(() => {
if (previousPath.current === location.pathname) {
return;
}
previousPath.current = location.pathname
setOpenSections((current) => {
const next = new Set(current)
items
.filter((item) => isItemActive(item, location.pathname))
.forEach((item) => next.add(item.title))
return next
})
}, [items, location.pathname])
previousPath.current = location.pathname;
setOpenSections((current) => {
const next = new Set(current);
items
.filter((item) => isItemActive(item, location.pathname))
.forEach((item) => {
next.add(item.title);
});
return next;
});
}, [items, location.pathname]);
return (
<SidebarGroup>
<SidebarGroupLabel>Commerce</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<Collapsible
key={item.title}
asChild
open={openSections.has(item.title)}
onOpenChange={(open) => {
setOpenSections((current) => {
const next = new Set(current)
if (open) {
next.add(item.title)
} else {
next.delete(item.title)
}
return next
})
}}
className="group/collapsible"
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={item.title}>
{item.icon && <item.icon />}
<span>{item.title}</span>
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton asChild>
<Link to={subItem.url}>
<span>{subItem.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
))}
</SidebarMenu>
</SidebarGroup>
)
return (
<SidebarGroup>
<SidebarGroupLabel>Commerce</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<Collapsible
key={item.title}
asChild
open={openSections.has(item.title)}
onOpenChange={(open) => {
setOpenSections((current) => {
const next = new Set(current);
if (open) {
next.add(item.title);
} else {
next.delete(item.title);
}
return next;
});
}}
className="group/collapsible"
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={item.title}>
{item.icon && <item.icon />}
<span>{item.title}</span>
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton asChild>
<Link to={subItem.url}>
<span>{subItem.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
))}
</SidebarMenu>
</SidebarGroup>
);
}
function readOpenSections() {
if (typeof window === "undefined") {
return []
}
if (typeof window === "undefined") {
return [];
}
const raw = window.localStorage.getItem(openSectionsStorageKey)
if (!raw) {
return []
}
const raw = window.localStorage.getItem(openSectionsStorageKey);
if (!raw) {
return [];
}
try {
const value = JSON.parse(raw)
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : []
} catch {
window.localStorage.removeItem(openSectionsStorageKey)
return []
}
try {
const value = JSON.parse(raw);
return Array.isArray(value)
? value.filter((item) => typeof item === "string")
: [];
} catch {
window.localStorage.removeItem(openSectionsStorageKey);
return [];
}
}
function writeOpenSections(sections: string[]) {
if (typeof window === "undefined") {
return
}
if (typeof window === "undefined") {
return;
}
window.localStorage.setItem(openSectionsStorageKey, JSON.stringify(sections))
window.localStorage.setItem(openSectionsStorageKey, JSON.stringify(sections));
}
function isItemActive(
item: {
url: string
isActive?: boolean
items?: { url: string }[]
},
pathname: string
item: {
url: string;
isActive?: boolean;
items?: { url: string }[];
},
pathname: string,
) {
if (item.items?.some((subItem) => pathname === subItem.url)) {
return true
}
if (item.items?.some((subItem) => pathname === subItem.url)) {
return true;
}
return pathname === item.url || (Boolean(item.isActive) && pathname === "/")
return pathname === item.url || (Boolean(item.isActive) && pathname === "/");
}
+66 -70
View File
@@ -1,79 +1,75 @@
"use client"
"use client";
import { Folder, type LucideIcon, MoreHorizontal } from "lucide-react";
import {
Folder,
MoreHorizontal,
type LucideIcon,
} from "lucide-react"
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "#/components/ui/dropdown-menu.tsx";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "#/components/ui/dropdown-menu.tsx"
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "#/components/ui/sidebar.tsx"
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "#/components/ui/sidebar.tsx";
export function NavProjects({
projects,
projects,
}: {
projects: {
name: string
url: string
icon: LucideIcon
}[]
projects: {
name: string;
url: string;
icon: LucideIcon;
}[];
}) {
const { isMobile } = useSidebar()
const { isMobile } = useSidebar();
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Shortcuts</SidebarGroupLabel>
<SidebarMenu>
{projects.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.name}</span>
</a>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction showOnHover>
<MoreHorizontal />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48 rounded-lg"
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}
>
<DropdownMenuItem asChild>
<a href={item.url}>
<Folder className="text-muted-foreground" />
<span>Open</span>
</a>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton className="text-sidebar-foreground/70">
<MoreHorizontal className="text-sidebar-foreground/70" />
<span>More</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
)
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Shortcuts</SidebarGroupLabel>
<SidebarMenu>
{projects.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.name}</span>
</a>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction showOnHover>
<MoreHorizontal />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48 rounded-lg"
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}
>
<DropdownMenuItem asChild>
<a href={item.url}>
<Folder className="text-muted-foreground" />
<span>Open</span>
</a>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton className="text-sidebar-foreground/70">
<MoreHorizontal className="text-sidebar-foreground/70" />
<span>More</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
);
}
@@ -1,42 +1,42 @@
"use client"
"use client";
import * as React from "react"
import { type Icon } from "@tabler/icons-react"
import type { Icon } from "@tabler/icons-react";
import type * as React from "react";
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "#/components/ui/sidebar.tsx"
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "#/components/ui/sidebar.tsx";
export function NavSecondary({
items,
...props
items,
...props
}: {
items: {
title: string
url: string
icon: Icon
}[]
items: {
title: string;
url: string;
icon: Icon;
}[];
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
return (
<SidebarGroup {...props}>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
return (
<SidebarGroup {...props}>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
+99 -99
View File
@@ -1,109 +1,109 @@
import {
BadgeCheck,
ChevronsUpDown,
LogOut,
} from "lucide-react"
import { BadgeCheck, ChevronsUpDown, LogOut } from "lucide-react";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "#/components/ui/avatar.tsx"
Avatar,
AvatarFallback,
AvatarImage,
} from "#/components/ui/avatar.tsx";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "#/components/ui/dropdown-menu.tsx"
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "#/components/ui/dropdown-menu.tsx";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "#/components/ui/sidebar.tsx"
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "#/components/ui/sidebar.tsx";
export function NavUser({
user,
onLogout,
user,
onLogout,
}: {
user: {
name: string
email: string
avatar: string
}
onLogout?: () => void
user: {
name: string;
email: string;
avatar: string;
};
onLogout?: () => void;
}) {
const { isMobile } = useSidebar()
const initials = user.name
.split(/\s+/)
.filter(Boolean)
.map((part) => part[0])
.join("")
.slice(0, 2)
.toUpperCase()
|| "CY"
const { isMobile } = useSidebar();
const initials =
user.name
.split(/\s+/)
.filter(Boolean)
.map((part) => part[0])
.join("")
.slice(0, 2)
.toUpperCase() || "CY";
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<BadgeCheck />
Account session
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault()
void onLogout?.()
}}
>
<LogOut />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">
{initials}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">
{initials}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<BadgeCheck />
Account session
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
void onLogout?.();
}}
>
<LogOut />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}
@@ -1,102 +1,102 @@
import { IconTrendingDown, IconTrendingUp } from "@tabler/icons-react"
import { IconTrendingDown, IconTrendingUp } from "@tabler/icons-react";
import { Badge } from "#/components/ui/badge.tsx"
import { Badge } from "#/components/ui/badge.tsx";
import {
Card,
CardAction,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "#/components/ui/card.tsx"
Card,
CardAction,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "#/components/ui/card.tsx";
export function SectionCards() {
return (
<div className="grid grid-cols-1 gap-4 px-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4 dark:*:data-[slot=card]:bg-card">
<Card className="@container/card">
<CardHeader>
<CardDescription>Total Revenue</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
$1,250.00
</CardTitle>
<CardAction>
<Badge variant="outline">
<IconTrendingUp />
+12.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Trending up this month <IconTrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">
Visitors for the last 6 months
</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>New Customers</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
1,234
</CardTitle>
<CardAction>
<Badge variant="outline">
<IconTrendingDown />
-20%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Down 20% this period <IconTrendingDown className="size-4" />
</div>
<div className="text-muted-foreground">
Acquisition needs attention
</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>Active Accounts</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
45,678
</CardTitle>
<CardAction>
<Badge variant="outline">
<IconTrendingUp />
+12.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Strong user retention <IconTrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">Engagement exceed targets</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>Growth Rate</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
4.5%
</CardTitle>
<CardAction>
<Badge variant="outline">
<IconTrendingUp />
+4.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Steady performance increase <IconTrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">Meets growth projections</div>
</CardFooter>
</Card>
</div>
)
return (
<div className="grid grid-cols-1 gap-4 px-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4 dark:*:data-[slot=card]:bg-card">
<Card className="@container/card">
<CardHeader>
<CardDescription>Total Revenue</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
$1,250.00
</CardTitle>
<CardAction>
<Badge variant="outline">
<IconTrendingUp />
+12.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Trending up this month <IconTrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">
Visitors for the last 6 months
</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>New Customers</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
1,234
</CardTitle>
<CardAction>
<Badge variant="outline">
<IconTrendingDown />
-20%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Down 20% this period <IconTrendingDown className="size-4" />
</div>
<div className="text-muted-foreground">
Acquisition needs attention
</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>Active Accounts</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
45,678
</CardTitle>
<CardAction>
<Badge variant="outline">
<IconTrendingUp />
+12.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Strong user retention <IconTrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">Engagement exceed targets</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>Growth Rate</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
4.5%
</CardTitle>
<CardAction>
<Badge variant="outline">
<IconTrendingUp />
+4.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Steady performance increase <IconTrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">Meets growth projections</div>
</CardFooter>
</Card>
</div>
);
}
+132 -128
View File
@@ -1,135 +1,139 @@
import { Button } from "#/components/ui/button.tsx"
import { Button } from "#/components/ui/button.tsx";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "#/components/ui/card.tsx"
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "#/components/ui/card.tsx";
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "#/components/ui/field.tsx"
import { Input } from "#/components/ui/input.tsx"
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "#/components/ui/field.tsx";
import { Input } from "#/components/ui/input.tsx";
type SignupFormProps = React.ComponentProps<typeof Card> & {
displayName: string
username: string
password: string
confirmPassword: string
error?: string | null
pending?: boolean
onDisplayNameChange: (value: string) => void
onUsernameChange: (value: string) => void
onPasswordChange: (value: string) => void
onConfirmPasswordChange: (value: string) => void
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void
onLoginClick: () => void
}
type SignupFormProps = Omit<React.ComponentProps<typeof Card>, "onSubmit"> & {
displayName: string;
username: string;
password: string;
confirmPassword: string;
error?: string | null;
pending?: boolean;
onDisplayNameChange: (value: string) => void;
onUsernameChange: (value: string) => void;
onPasswordChange: (value: string) => void;
onConfirmPasswordChange: (value: string) => void;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void | Promise<void>;
onLoginClick: () => void;
};
export function SignupForm({
displayName,
username,
password,
confirmPassword,
error,
pending = false,
onDisplayNameChange,
onUsernameChange,
onPasswordChange,
onConfirmPasswordChange,
onSubmit,
onLoginClick,
...props
displayName,
username,
password,
confirmPassword,
error,
pending = false,
onDisplayNameChange,
onUsernameChange,
onPasswordChange,
onConfirmPasswordChange,
onSubmit,
onLoginClick,
...props
}: SignupFormProps) {
return (
<Card {...props}>
<CardHeader>
<CardTitle>Create an account</CardTitle>
<CardDescription>
Create a customer account to place orders and request rides.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="signup-display-name">Display name</FieldLabel>
<Input
id="signup-display-name"
type="text"
autoComplete="name"
value={displayName}
onChange={(event) => onDisplayNameChange(event.target.value)}
required
/>
</Field>
<Field>
<FieldLabel htmlFor="signup-username">Username</FieldLabel>
<Input
id="signup-username"
type="text"
autoComplete="username"
value={username}
onChange={(event) => onUsernameChange(event.target.value)}
required
/>
<FieldDescription>
This is what you will use when signing in.
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="signup-password">Password</FieldLabel>
<Input
id="signup-password"
type="password"
autoComplete="new-password"
value={password}
onChange={(event) => onPasswordChange(event.target.value)}
required
/>
<FieldDescription>
Must be at least 8 characters long.
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="signup-confirm-password">
Confirm Password
</FieldLabel>
<Input
id="signup-confirm-password"
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={(event) => onConfirmPasswordChange(event.target.value)}
required
/>
<FieldDescription>Please confirm your password.</FieldDescription>
</Field>
{error ? <FieldError>{error}</FieldError> : null}
<FieldGroup>
<Field>
<Button type="submit" disabled={pending}>
{pending ? "Creating account..." : "Create account"}
</Button>
<FieldDescription className="px-6 text-center">
Already have an account?{" "}
<button
type="button"
className="underline underline-offset-4 hover:text-primary"
onClick={onLoginClick}
>
Sign in
</button>
</FieldDescription>
</Field>
</FieldGroup>
</FieldGroup>
</form>
</CardContent>
</Card>
)
return (
<Card {...props}>
<CardHeader>
<CardTitle>Create an account</CardTitle>
<CardDescription>
Create a customer account to place orders and request rides.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="signup-display-name">
Display name
</FieldLabel>
<Input
id="signup-display-name"
type="text"
autoComplete="name"
value={displayName}
onChange={(event) => onDisplayNameChange(event.target.value)}
required
/>
</Field>
<Field>
<FieldLabel htmlFor="signup-username">Username</FieldLabel>
<Input
id="signup-username"
type="text"
autoComplete="username"
value={username}
onChange={(event) => onUsernameChange(event.target.value)}
required
/>
<FieldDescription>
This is what you will use when signing in.
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="signup-password">Password</FieldLabel>
<Input
id="signup-password"
type="password"
autoComplete="new-password"
value={password}
onChange={(event) => onPasswordChange(event.target.value)}
required
/>
<FieldDescription>
Must be at least 8 characters long.
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="signup-confirm-password">
Confirm Password
</FieldLabel>
<Input
id="signup-confirm-password"
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={(event) =>
onConfirmPasswordChange(event.target.value)
}
required
/>
<FieldDescription>Please confirm your password.</FieldDescription>
</Field>
{error ? <FieldError>{error}</FieldError> : null}
<FieldGroup>
<Field>
<Button type="submit" disabled={pending}>
{pending ? "Creating account..." : "Create account"}
</Button>
<FieldDescription className="px-6 text-center">
Already have an account?{" "}
<button
type="button"
className="underline underline-offset-4 hover:text-primary"
onClick={onLoginClick}
>
Sign in
</button>
</FieldDescription>
</Field>
</FieldGroup>
</FieldGroup>
</form>
</CardContent>
</Card>
);
}
+43 -37
View File
@@ -1,42 +1,48 @@
import { Link } from "@tanstack/react-router"
import { ShoppingCart } from "lucide-react"
import { Button } from "#/components/ui/button.tsx"
import { Separator } from "#/components/ui/separator.tsx"
import { SidebarTrigger } from "#/components/ui/sidebar.tsx"
import { ThemeToggle } from "#/components/theme-toggle.tsx"
import { useCart } from "#/hooks/useCart.tsx"
import { Link } from "@tanstack/react-router";
import { ShoppingCart } from "lucide-react";
import { ThemeToggle } from "#/components/theme-toggle.tsx";
import { Button } from "#/components/ui/button.tsx";
import { Separator } from "#/components/ui/separator.tsx";
import { SidebarTrigger } from "#/components/ui/sidebar.tsx";
import { useCart } from "#/hooks/useCart.tsx";
type SiteHeaderProps = {
title: string
onLogout: () => void
showCart?: boolean
}
title: string;
onLogout: () => void;
showCart?: boolean;
};
export function SiteHeader({ title, onLogout, showCart = false }: SiteHeaderProps) {
const cart = useCart()
export function SiteHeader({
title,
onLogout,
showCart = false,
}: SiteHeaderProps) {
const cart = useCart();
return (
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mx-2 data-[orientation=vertical]:h-4" />
<h1 className="text-base font-medium">{title}</h1>
<div className="ml-auto flex items-center gap-2">
<ThemeToggle />
{showCart ? (
<Button asChild variant="outline" size="sm">
<Link to="/cart">
<ShoppingCart />
Cart ({cart.itemCount})
</Link>
</Button>
) : null}
<Button type="button" variant="outline" size="sm" onClick={onLogout}>
Sign out
</Button>
</div>
</div>
</header>
)
return (
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="mx-2 data-[orientation=vertical]:h-4"
/>
<h1 className="text-base font-medium">{title}</h1>
<div className="ml-auto flex items-center gap-2">
<ThemeToggle />
{showCart ? (
<Button asChild variant="outline" size="sm">
<Link to="/cart">
<ShoppingCart />
Cart ({cart.itemCount})
</Link>
</Button>
) : null}
<Button type="button" variant="outline" size="sm" onClick={onLogout}>
Sign out
</Button>
</div>
</div>
</header>
);
}
@@ -1,128 +1,133 @@
import { LoginForm } from "#/components/login-form.tsx"
import { SignupForm } from "#/components/signup-form.tsx"
import { Dialog, DialogContent } from "#/components/ui/dialog.tsx"
import { useEffect, useState } from "react"
import { useEffect, useState } from "react";
import { LoginForm } from "#/components/login-form.tsx";
import { SignupForm } from "#/components/signup-form.tsx";
import { Dialog, DialogContent } from "#/components/ui/dialog.tsx";
type AuthDialogProps = {
open: boolean
initialMode: "login" | "register"
onOpenChange: (open: boolean) => void
onLogin: (request: { username: string; password: string }) => Promise<unknown>
onRegister: (request: {
username: string
password: string
displayName: string
}) => Promise<unknown>
}
open: boolean;
initialMode: "login" | "register";
onOpenChange: (open: boolean) => void;
onLogin: (request: {
username: string;
password: string;
}) => Promise<unknown>;
onRegister: (request: {
username: string;
password: string;
displayName: string;
}) => Promise<unknown>;
};
export function AuthDialog({
open,
initialMode,
onOpenChange,
onLogin,
onRegister,
open,
initialMode,
onOpenChange,
onLogin,
onRegister,
}: AuthDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md border-0 bg-transparent p-0 shadow-none">
<AuthForm
initialMode={initialMode}
onLogin={onLogin}
onRegister={onRegister}
/>
</DialogContent>
</Dialog>
)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md border-0 bg-transparent p-0 shadow-none">
<AuthForm
initialMode={initialMode}
onLogin={onLogin}
onRegister={onRegister}
/>
</DialogContent>
</Dialog>
);
}
export function AuthForm({
initialMode,
onLogin,
onRegister,
initialMode,
onLogin,
onRegister,
}: Omit<AuthDialogProps, "open" | "onOpenChange">) {
const [mode, setMode] = useState<"login" | "register">(initialMode)
const [username, setUsername] = useState("")
const [displayName, setDisplayName] = useState("")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [error, setError] = useState<string | null>(null)
const [pending, setPending] = useState(false)
const [mode, setMode] = useState<"login" | "register">(initialMode);
const [username, setUsername] = useState("");
const [displayName, setDisplayName] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
useEffect(() => {
setMode(initialMode)
setError(null)
}, [initialMode])
useEffect(() => {
setMode(initialMode);
setError(null);
}, [initialMode]);
async function handleLogin(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setError(null)
setPending(true)
async function handleLogin(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setPending(true);
try {
await onLogin({ username, password })
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Sign in failed")
} finally {
setPending(false)
}
}
try {
await onLogin({ username, password });
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Sign in failed");
} finally {
setPending(false);
}
}
async function handleSignup(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setError(null)
async function handleSignup(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (password !== confirmPassword) {
setError("Passwords do not match")
return
}
if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}
setPending(true)
setPending(true);
try {
await onRegister({ username, displayName, password })
await onLogin({ username, password })
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Account creation failed")
} finally {
setPending(false)
}
}
try {
await onRegister({ username, displayName, password });
await onLogin({ username, password });
} catch (caught) {
setError(
caught instanceof Error ? caught.message : "Account creation failed",
);
} finally {
setPending(false);
}
}
if (mode === "register") {
return (
<SignupForm
displayName={displayName}
username={username}
password={password}
confirmPassword={confirmPassword}
error={error}
pending={pending}
onDisplayNameChange={setDisplayName}
onUsernameChange={setUsername}
onPasswordChange={setPassword}
onConfirmPasswordChange={setConfirmPassword}
onSubmit={handleSignup}
onLoginClick={() => {
setMode("login")
setError(null)
}}
/>
)
}
if (mode === "register") {
return (
<SignupForm
displayName={displayName}
username={username}
password={password}
confirmPassword={confirmPassword}
error={error}
pending={pending}
onDisplayNameChange={setDisplayName}
onUsernameChange={setUsername}
onPasswordChange={setPassword}
onConfirmPasswordChange={setConfirmPassword}
onSubmit={handleSignup}
onLoginClick={() => {
setMode("login");
setError(null);
}}
/>
);
}
return (
<LoginForm
username={username}
password={password}
error={error}
pending={pending}
onUsernameChange={setUsername}
onPasswordChange={setPassword}
onSubmit={handleLogin}
onSignupClick={() => {
setMode("register")
setError(null)
}}
/>
)
return (
<LoginForm
username={username}
password={password}
error={error}
pending={pending}
onUsernameChange={setUsername}
onPasswordChange={setPassword}
onSubmit={handleLogin}
onSignupClick={() => {
setMode("register");
setError(null);
}}
/>
);
}
@@ -1,69 +1,82 @@
import {QuantityStepper} from "#/components/store/QuantityStepper";
import {Button} from "#/components/ui/button";
import {Card, CardContent, CardFooter, CardHeader, CardTitle} from "#/components/ui/card";
import {StatusBadge} from "#/components/ui/status-badge";
import {formatCurrency} from "#/hooks/formatCurrency";
import type {Product} from "#/types/product";
import {Link} from "@tanstack/react-router";
import {ShoppingCart} from "lucide-react";
import {useState} from "react";
import {toast} from "sonner";
import { Link } from "@tanstack/react-router";
import { ShoppingCart } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { QuantityStepper } from "#/components/store/QuantityStepper";
import { Button } from "#/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "#/components/ui/card";
import { StatusBadge } from "#/components/ui/status-badge";
import { formatCurrency } from "#/hooks/formatCurrency";
import type { Product } from "#/types/product";
type ProductCardProps = {
product: Product;
onAddToCart?: (product: Product, quantity: number) => void;
product: Product;
onAddToCart?: (product: Product, quantity: number) => void;
};
export function ProductCard({product, onAddToCart}: ProductCardProps) {
const [quantity, setQuantity] = useState(1);
const inStock = product.availableQuantity > 0;
export function ProductCard({ product, onAddToCart }: ProductCardProps) {
const [quantity, setQuantity] = useState(1);
const inStock = product.availableQuantity > 0;
return (
<Card className="gap-4">
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>{product.name}</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">{product.sku}</p>
</div>
<StatusBadge status={inStock ? "IN_STOCK" : "OUT_OF_STOCK"} label={inStock ? "In stock" : "Out"}/>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-2xl font-semibold">{formatCurrency(product.price, product.currency)}</p>
<p className="text-sm text-muted-foreground">{product.availableQuantity} available</p>
{onAddToCart ? (
<div className="flex flex-wrap items-center justify-between gap-3">
<QuantityStepper
value={Math.min(quantity, Math.max(product.availableQuantity, 1))}
min={1}
max={Math.max(product.availableQuantity, 1)}
disabled={!inStock}
onChange={setQuantity}
/>
<Button
type="button"
disabled={!inStock}
onClick={() => {
onAddToCart(product, quantity);
toast.success("Added to cart", {
description: `${quantity} x ${product.name}`,
});
}}
>
<ShoppingCart/>
Add
</Button>
</div>
) : null}
</CardContent>
<CardFooter>
<Button asChild variant="outline" className="w-full">
<Link to="/products/$productId" params={{productId: product.id}}>
View details
</Link>
</Button>
</CardFooter>
</Card>
);
return (
<Card className="gap-4">
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>{product.name}</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">{product.sku}</p>
</div>
<StatusBadge
status={inStock ? "IN_STOCK" : "OUT_OF_STOCK"}
label={inStock ? "In stock" : "Out"}
/>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-2xl font-semibold">
{formatCurrency(product.price, product.currency)}
</p>
<p className="text-sm text-muted-foreground">
{product.availableQuantity} available
</p>
{onAddToCart ? (
<div className="flex flex-wrap items-center justify-between gap-3">
<QuantityStepper
value={Math.min(quantity, Math.max(product.availableQuantity, 1))}
min={1}
max={Math.max(product.availableQuantity, 1)}
disabled={!inStock}
onChange={setQuantity}
/>
<Button
type="button"
disabled={!inStock}
onClick={() => {
onAddToCart(product, quantity);
toast.success("Added to cart", {
description: `${quantity} x ${product.name}`,
});
}}
>
<ShoppingCart />
Add
</Button>
</div>
) : null}
</CardContent>
<CardFooter>
<Button asChild variant="outline" className="w-full">
<Link to="/products/$productId" params={{ productId: product.id }}>
View details
</Link>
</Button>
</CardFooter>
</Card>
);
}
@@ -1,31 +1,39 @@
import {ProductCard} from "#/components/store/ProductCard";
import {LoadingState} from "#/components/ui/LoadingState";
import type {Product} from "#/types/product";
import { ProductCard } from "#/components/store/ProductCard";
import { LoadingState } from "#/components/ui/LoadingState";
import type { Product } from "#/types/product";
type ProductGridProps = {
products: Product[];
isLoading: boolean;
onAddToCart?: (product: Product, quantity: number) => void;
products: Product[];
isLoading: boolean;
onAddToCart?: (product: Product, quantity: number) => void;
};
export function ProductGrid({products, isLoading, onAddToCart}: ProductGridProps) {
if (isLoading) {
return <LoadingState title="Loading products"/>;
}
export function ProductGrid({
products,
isLoading,
onAddToCart,
}: ProductGridProps) {
if (isLoading) {
return <LoadingState title="Loading products" />;
}
if (products.length === 0) {
return (
<div className="rounded-lg border border-dashed border-border bg-card p-8 text-center text-sm text-muted-foreground">
No products available.
</div>
);
}
if (products.length === 0) {
return (
<div className="rounded-lg border border-dashed border-border bg-card p-8 text-center text-sm text-muted-foreground">
No products available.
</div>
);
}
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{products.map((product) => (
<ProductCard key={product.id} product={product} onAddToCart={onAddToCart}/>
))}
</div>
);
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{products.map((product) => (
<ProductCard
key={product.id}
product={product}
onAddToCart={onAddToCart}
/>
))}
</div>
);
}
@@ -1,61 +1,61 @@
import {Button} from "#/components/ui/button";
import {Input} from "#/components/ui/input";
import { Button } from "#/components/ui/button";
import { Input } from "#/components/ui/input";
type QuantityStepperProps = {
value: number;
min?: number;
max: number;
disabled?: boolean;
onChange: (value: number) => void;
value: number;
min?: number;
max: number;
disabled?: boolean;
onChange: (value: number) => void;
};
export function QuantityStepper({
value,
min = 1,
max,
disabled = false,
onChange,
value,
min = 1,
max,
disabled = false,
onChange,
}: QuantityStepperProps) {
const normalizedMax = Math.max(max, min);
const normalizedMax = Math.max(max, min);
function setQuantity(nextValue: number) {
if (Number.isNaN(nextValue)) {
onChange(min);
return;
}
function setQuantity(nextValue: number) {
if (Number.isNaN(nextValue)) {
onChange(min);
return;
}
onChange(Math.min(Math.max(nextValue, min), normalizedMax));
}
onChange(Math.min(Math.max(nextValue, min), normalizedMax));
}
return (
<div className="inline-grid grid-cols-[2.25rem_4rem_2.25rem] overflow-hidden rounded-md border border-input">
<Button
type="button"
variant="secondary"
disabled={disabled || value <= min}
onClick={() => setQuantity(value - 1)}
className="h-9 rounded-none border-0 shadow-none"
>
-
</Button>
<Input
type="number"
min={min}
max={normalizedMax}
value={value}
disabled={disabled}
onChange={(event) => setQuantity(Number(event.target.value))}
className="h-9 rounded-none border-y-0 px-2 text-center shadow-none"
/>
<Button
type="button"
variant="secondary"
disabled={disabled || value >= normalizedMax}
onClick={() => setQuantity(value + 1)}
className="h-9 rounded-none border-0 shadow-none"
>
+
</Button>
</div>
);
return (
<div className="inline-grid grid-cols-[2.25rem_4rem_2.25rem] overflow-hidden rounded-md border border-input">
<Button
type="button"
variant="secondary"
disabled={disabled || value <= min}
onClick={() => setQuantity(value - 1)}
className="h-9 rounded-none border-0 shadow-none"
>
-
</Button>
<Input
type="number"
min={min}
max={normalizedMax}
value={value}
disabled={disabled}
onChange={(event) => setQuantity(Number(event.target.value))}
className="h-9 rounded-none border-y-0 px-2 text-center shadow-none"
/>
<Button
type="button"
variant="secondary"
disabled={disabled || value >= normalizedMax}
onClick={() => setQuantity(value + 1)}
className="h-9 rounded-none border-0 shadow-none"
>
+
</Button>
</div>
);
}
@@ -1,54 +1,67 @@
import {Button} from "#/components/ui/button";
import {ThemeToggle} from "#/components/theme-toggle";
import {useCart} from "#/hooks/useCart";
import type {AuthUser} from "#/types/auth";
import {Link} from "@tanstack/react-router";
import {LogOut, ShoppingCart, User} from "lucide-react";
import { Link } from "@tanstack/react-router";
import { LogOut, ShoppingCart, User } from "lucide-react";
import { ThemeToggle } from "#/components/theme-toggle";
import { Button } from "#/components/ui/button";
import { useCart } from "#/hooks/useCart";
import type { AuthUser } from "#/types/auth";
type SiteHeaderProps = {
user?: AuthUser | null;
onLogin?: () => void;
onRegister?: () => void;
onLogout?: () => void;
user?: AuthUser | null;
onLogin?: () => void;
onRegister?: () => void;
onLogout?: () => void;
};
export function SiteHeader({user, onLogin, onRegister, onLogout}: SiteHeaderProps) {
const cart = useCart();
export function SiteHeader({
user,
onLogin,
onRegister,
onLogout,
}: SiteHeaderProps) {
const cart = useCart();
return (
<header className="sticky top-0 z-30 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex max-w-7xl flex-col gap-4 px-4 py-4 md:flex-row md:items-center md:justify-between">
<div>
<Link to="/" className="text-2xl font-bold text-foreground">Cymelle</Link>
<p className="text-sm text-muted-foreground">Commerce, delivery, and rides</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<ThemeToggle/>
<Button asChild variant="outline">
<Link to="/cart">
<ShoppingCart/>
Cart ({cart.itemCount})
</Link>
</Button>
{user ? (
<>
<div className="inline-flex items-center gap-2 rounded-md border border-border bg-card px-3 py-2 text-sm">
<User className="size-4"/>
<span>{user.displayName}</span>
</div>
<Button type="button" variant="outline" onClick={onLogout}>
<LogOut/>
Sign out
</Button>
</>
) : (
<>
<Button type="button" onClick={onLogin}>Sign in</Button>
<Button type="button" variant="outline" onClick={onRegister}>Create account</Button>
</>
)}
</div>
</div>
</header>
);
return (
<header className="sticky top-0 z-30 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex max-w-7xl flex-col gap-4 px-4 py-4 md:flex-row md:items-center md:justify-between">
<div>
<Link to="/" className="text-2xl font-bold text-foreground">
Cymelle
</Link>
<p className="text-sm text-muted-foreground">
Commerce, delivery, and rides
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<ThemeToggle />
<Button asChild variant="outline">
<Link to="/cart">
<ShoppingCart />
Cart ({cart.itemCount})
</Link>
</Button>
{user ? (
<>
<div className="inline-flex items-center gap-2 rounded-md border border-border bg-card px-3 py-2 text-sm">
<User className="size-4" />
<span>{user.displayName}</span>
</div>
<Button type="button" variant="outline" onClick={onLogout}>
<LogOut />
Sign out
</Button>
</>
) : (
<>
<Button type="button" onClick={onLogin}>
Sign in
</Button>
<Button type="button" variant="outline" onClick={onRegister}>
Create account
</Button>
</>
)}
</div>
</div>
</header>
);
}
@@ -1,60 +1,68 @@
import {AuthDialog} from "#/components/store/AuthDialog";
import {ProductGrid} from "#/components/store/ProductGrid";
import {SiteHeader} from "#/components/store/SiteHeader";
import {useAuth} from "#/hooks/useAuth";
import {useCart} from "#/hooks/useCart";
import {useProducts} from "#/hooks/useProducts";
import {navigateToRoleHome} from "#/lib/auth-navigation";
import {useNavigate} from "@tanstack/react-router";
import {useState} from "react";
import { useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { AuthDialog } from "#/components/store/AuthDialog";
import { ProductGrid } from "#/components/store/ProductGrid";
import { SiteHeader } from "#/components/store/SiteHeader";
import { useAuth } from "#/hooks/useAuth";
import { useCart } from "#/hooks/useCart";
import { useProducts } from "#/hooks/useProducts";
import { navigateToRoleHome } from "#/lib/auth-navigation";
export function StoreHome() {
const {user, login, register, logout} = useAuth();
const productsQuery = useProducts();
const cart = useCart();
const navigate = useNavigate();
const [authMode, setAuthMode] = useState<"login" | "register" | null>(null);
const { user, login, register, logout } = useAuth();
const productsQuery = useProducts();
const cart = useCart();
const navigate = useNavigate();
const [authMode, setAuthMode] = useState<"login" | "register" | null>(null);
async function handleLogin(request: {username: string; password: string}) {
const user = await login(request);
setAuthMode(null);
await navigateToRoleHome(navigate, user);
return user;
}
async function handleLogin(request: { username: string; password: string }) {
const user = await login(request);
setAuthMode(null);
await navigateToRoleHome(navigate, user);
return user;
}
async function handleRegister(request: {username: string; password: string; displayName: string}) {
const user = await register(request);
setAuthMode(null);
await navigateToRoleHome(navigate, user);
return user;
}
async function handleRegister(request: {
username: string;
password: string;
displayName: string;
}) {
const user = await register(request);
setAuthMode(null);
await navigateToRoleHome(navigate, user);
return user;
}
return (
<main className="min-h-screen bg-background">
<SiteHeader
user={user}
onLogin={() => setAuthMode("login")}
onRegister={() => setAuthMode("register")}
onLogout={logout}
/>
<section className="mx-auto max-w-7xl px-4 py-8">
<div className="mb-6 max-w-2xl">
<p className="text-sm font-medium uppercase text-muted-foreground">Store</p>
<h1 className="mt-2 text-4xl font-bold tracking-normal text-foreground">Shop Cymelle products</h1>
</div>
<ProductGrid
products={productsQuery.data ?? []}
isLoading={productsQuery.isLoading}
onAddToCart={cart.addItem}
/>
</section>
<AuthDialog
open={authMode !== null}
initialMode={authMode ?? "login"}
onOpenChange={(open) => !open && setAuthMode(null)}
onLogin={handleLogin}
onRegister={handleRegister}
/>
</main>
);
return (
<main className="min-h-screen bg-background">
<SiteHeader
user={user}
onLogin={() => setAuthMode("login")}
onRegister={() => setAuthMode("register")}
onLogout={logout}
/>
<section className="mx-auto max-w-7xl px-4 py-8">
<div className="mb-6 max-w-2xl">
<p className="text-sm font-medium uppercase text-muted-foreground">
Store
</p>
<h1 className="mt-2 text-4xl font-bold tracking-normal text-foreground">
Shop Cymelle products
</h1>
</div>
<ProductGrid
products={productsQuery.data ?? []}
isLoading={productsQuery.isLoading}
onAddToCart={cart.addItem}
/>
</section>
<AuthDialog
open={authMode !== null}
initialMode={authMode ?? "login"}
onOpenChange={(open) => !open && setAuthMode(null)}
onLogin={handleLogin}
onRegister={handleRegister}
/>
</main>
);
}
@@ -1,81 +1,81 @@
"use client"
"use client";
import * as React from "react"
import { ChevronsUpDown } from "lucide-react"
import { ChevronsUpDown } from "lucide-react";
import * as React from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "#/components/ui/dropdown-menu.tsx"
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "#/components/ui/dropdown-menu.tsx";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "#/components/ui/sidebar.tsx"
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "#/components/ui/sidebar.tsx";
export function TeamSwitcher({
teams,
teams,
}: {
teams: {
name: string
logo: React.ElementType
plan: string
}[]
teams: {
name: string;
logo: React.ElementType;
plan: string;
}[];
}) {
const { isMobile } = useSidebar()
const [activeTeam, setActiveTeam] = React.useState(teams[0])
const { isMobile } = useSidebar();
const [activeTeam, setActiveTeam] = React.useState(teams[0]);
if (!activeTeam) {
return null
}
if (!activeTeam) {
return null;
}
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<activeTeam.logo className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{activeTeam.name}</span>
<span className="truncate text-xs">{activeTeam.plan}</span>
</div>
<ChevronsUpDown className="ml-auto" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
align="start"
side={isMobile ? "bottom" : "right"}
sideOffset={4}
>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Workspace
</DropdownMenuLabel>
{teams.map((team, index) => (
<DropdownMenuItem
key={team.name}
onClick={() => setActiveTeam(team)}
className="gap-2 p-2"
>
<div className="flex size-6 items-center justify-center rounded-md border">
<team.logo className="size-3.5 shrink-0" />
</div>
{team.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<activeTeam.logo className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{activeTeam.name}</span>
<span className="truncate text-xs">{activeTeam.plan}</span>
</div>
<ChevronsUpDown className="ml-auto" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
align="start"
side={isMobile ? "bottom" : "right"}
sideOffset={4}
>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Workspace
</DropdownMenuLabel>
{teams.map((team) => (
<DropdownMenuItem
key={team.name}
onClick={() => setActiveTeam(team)}
className="gap-2 p-2"
>
<div className="flex size-6 items-center justify-center rounded-md border">
<team.logo className="size-3.5 shrink-0" />
</div>
{team.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}
@@ -1,14 +1,14 @@
import {ThemeProvider as NextThemesProvider} from "next-themes";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({children}: {children: React.ReactNode}) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</NextThemesProvider>
);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</NextThemesProvider>
);
}
+32 -32
View File
@@ -1,38 +1,38 @@
import {Button} from "#/components/ui/button";
import { Monitor, Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "#/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "#/components/ui/dropdown-menu";
import {Monitor, Moon, Sun} from "lucide-react";
import {useTheme} from "next-themes";
export function ThemeToggle() {
const {setTheme} = useTheme();
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" aria-label="Toggle theme">
<Sun className="size-4 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90"/>
<Moon className="absolute size-4 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0"/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
<Sun/>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<Moon/>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<Monitor/>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" aria-label="Toggle theme">
<Sun className="size-4 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute size-4 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
<Sun />
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<Moon />
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<Monitor />
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -1,13 +1,13 @@
type EmptyStateProps = {
title: string;
description: string;
title: string;
description: string;
};
export function EmptyState({title, description}: EmptyStateProps) {
return (
<div className="rounded-xl border border-dashed border-slate-300 bg-white p-8 text-center">
<h3 className="font-semibold text-slate-900">{title}</h3>
<p className="mt-1 text-sm text-slate-500">{description}</p>
</div>
);
}
export function EmptyState({ title, description }: EmptyStateProps) {
return (
<div className="rounded-xl border border-dashed border-slate-300 bg-white p-8 text-center">
<h3 className="font-semibold text-slate-900">{title}</h3>
<p className="mt-1 text-sm text-slate-500">{description}</p>
</div>
);
}
@@ -1,28 +1,28 @@
type ErrorStateProps = {
title?: string;
message: string;
onRetry?: () => void;
title?: string;
message: string;
onRetry?: () => void;
};
export function ErrorState({
title = "Something went wrong",
message,
onRetry,
}: ErrorStateProps) {
return (
<div className="rounded-xl border border-red-200 bg-red-50 p-8 text-center">
<h3 className="font-semibold text-red-900">{title}</h3>
<p className="mt-1 text-sm text-red-700">{message}</p>
title = "Something went wrong",
message,
onRetry,
}: ErrorStateProps) {
return (
<div className="rounded-xl border border-red-200 bg-red-50 p-8 text-center">
<h3 className="font-semibold text-red-900">{title}</h3>
<p className="mt-1 text-sm text-red-700">{message}</p>
{onRetry ? (
<button
type="button"
onClick={onRetry}
className="mt-4 rounded-lg bg-red-700 px-4 py-2 text-sm font-medium text-white hover:bg-red-800"
>
Try again
</button>
) : null}
</div>
);
}
{onRetry ? (
<button
type="button"
onClick={onRetry}
className="mt-4 rounded-lg bg-red-700 px-4 py-2 text-sm font-medium text-white hover:bg-red-800"
>
Try again
</button>
) : null}
</div>
);
}
@@ -1,21 +1,22 @@
type LoadingStateProps = {
title?: string;
title?: string;
};
export function LoadingState({
title = "Loading",
}: LoadingStateProps) {
return (
<div className="rounded-lg border bg-card p-6 text-card-foreground shadow-sm" aria-label={title}>
<div className="space-y-3">
<div className="h-4 w-36 animate-pulse rounded-md bg-muted"/>
<div className="h-8 w-full animate-pulse rounded-md bg-muted"/>
<div className="grid gap-3 md:grid-cols-3">
<div className="h-16 animate-pulse rounded-md bg-muted"/>
<div className="h-16 animate-pulse rounded-md bg-muted"/>
<div className="h-16 animate-pulse rounded-md bg-muted"/>
</div>
</div>
</div>
);
export function LoadingState({ title = "Loading" }: LoadingStateProps) {
return (
<output
className="rounded-lg border bg-card p-6 text-card-foreground shadow-sm"
aria-label={title}
>
<div className="space-y-3">
<div className="h-4 w-36 animate-pulse rounded-md bg-muted" />
<div className="h-8 w-full animate-pulse rounded-md bg-muted" />
<div className="grid gap-3 md:grid-cols-3">
<div className="h-16 animate-pulse rounded-md bg-muted" />
<div className="h-16 animate-pulse rounded-md bg-muted" />
<div className="h-16 animate-pulse rounded-md bg-muted" />
</div>
</div>
</output>
);
}
+82 -82
View File
@@ -1,109 +1,109 @@
"use client"
"use client";
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import { Avatar as AvatarPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "#/lib/utils.ts"
import { cn } from "#/lib/utils.ts";
function Avatar({
className,
size = "default",
...props
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg"
size?: "default" | "sm" | "lg";
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
className
)}
{...props}
/>
)
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
className,
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className,
)}
{...props}
/>
);
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className,
)}
{...props}
/>
);
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className,
)}
{...props}
/>
);
}
function AvatarGroupCount({
className,
...props
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className,
)}
{...props}
/>
);
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarBadge,
AvatarGroup,
AvatarGroupCount,
}
Avatar,
AvatarImage,
AvatarFallback,
AvatarBadge,
AvatarGroup,
AvatarGroupCount,
};
+39 -39
View File
@@ -1,48 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import type * as React from "react";
import { cn } from "#/lib/utils.ts"
import { cn } from "#/lib/utils.ts";
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant = "default",
asChild = false,
...props
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span";
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };
@@ -1,109 +1,107 @@
import * as React from "react"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { Slot } from "radix-ui"
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { Slot } from "radix-ui";
import type * as React from "react";
import { cn } from "#/lib/utils.ts"
import { cn } from "#/lib/utils.ts";
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
)
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2.5",
className,
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
);
}
function BreadcrumbLink({
asChild,
className,
...props
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "a"
const Comp = asChild ? Slot.Root : "a";
return (
<Comp
data-slot="breadcrumb-link"
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
return (
<Comp
data-slot="breadcrumb-link"
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
)
return (
<span
data-slot="breadcrumb-page"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
);
}
function BreadcrumbSeparator({
children,
className,
...props
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({
className,
...props
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};
+55 -55
View File
@@ -1,64 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import type * as React from "react";
import { cn } from "#/lib/utils.ts"
import { cn } from "#/lib/utils.ts";
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants }
export { Button, buttonVariants };
+68 -68
View File
@@ -1,92 +1,92 @@
import * as React from "react"
import type * as React from "react";
import { cn } from "#/lib/utils.ts"
import { cn } from "#/lib/utils.ts";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};
+309 -311
View File
@@ -1,372 +1,370 @@
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import type { TooltipValueType } from "recharts"
import * as React from "react";
import type { TooltipValueType } from "recharts";
import * as RechartsPrimitive from "recharts";
import { cn } from "#/lib/utils.ts"
import { cn } from "#/lib/utils.ts";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
const THEMES = { light: "", dark: ".dark" } as const;
const INITIAL_DIMENSION = { width: 320, height: 200 } as const
type TooltipNameType = number | string
const INITIAL_DIMENSION = { width: 320, height: 200 } as const;
type TooltipNameType = number | string;
export type ChartConfig = Record<
string,
{
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
>
string,
{
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
>;
type ChartContextProps = {
config: ChartConfig
}
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null)
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext)
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context
return context;
}
function ChartContainer({
id,
className,
children,
config,
initialDimension = INITIAL_DIMENSION,
...props
id,
className,
children,
config,
initialDimension = INITIAL_DIMENSION,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
initialDimension?: {
width: number
height: number
}
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
initialDimension?: {
width: number;
height: number;
};
}) {
const uniqueId = React.useId()
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
const uniqueId = React.useId();
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer
initialDimension={initialDimension}
>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer
initialDimension={initialDimension}
>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme ?? config.color
)
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme ?? config.color,
);
if (!colorConfig.length) {
return null
}
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
const css = Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n");
const ChartTooltip = RechartsPrimitive.Tooltip
return <style>{css}</style>;
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
} & Omit<
RechartsPrimitive.DefaultTooltipContentProps<
TooltipValueType,
TooltipNameType
>,
"accessibilityLayer"
>) {
const { config } = useChart()
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
} & Omit<
RechartsPrimitive.DefaultTooltipContentProps<
TooltipValueType,
TooltipNameType
>,
"accessibilityLayer"
>) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? (config[label]?.label ?? label)
: itemConfig?.label
const [item] = payload;
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? (config[label]?.label ?? label)
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null
}
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot"
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color ?? item.payload?.fill ?? item.color
return (
<div
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`;
const itemKey = `${key}-${String(item.value ?? item.color ?? "")}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color ?? item.payload?.fill ?? item.color;
return (
<div
key={index}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label ?? item.name}
</span>
</div>
{item.value != null && (
<span className="font-mono font-medium text-foreground tabular-nums">
{typeof item.value === "number"
? item.value.toLocaleString()
: String(item.value)}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
return (
<div
key={itemKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label ?? item.name}
</span>
</div>
{item.value != null && (
<span className="font-mono font-medium text-foreground tabular-nums">
{typeof item.value === "number"
? item.value.toLocaleString()
: String(item.value)}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> & {
hideIcon?: boolean
nameKey?: string
hideIcon?: boolean;
nameKey?: string;
} & RechartsPrimitive.DefaultLegendContentProps) {
const { config } = useChart()
const { config } = useChart();
if (!payload?.length) {
return null
}
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey ?? item.dataKey ?? "value"}`;
const itemKey = `${key}-${String(item.value ?? item.color ?? "")}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={index}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
return (
<div
key={itemKey}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key]
return configLabelKey in config ? config[configLabelKey] : config[key];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};
+24 -24
View File
@@ -1,30 +1,30 @@
import * as React from "react"
import { CheckIcon } from "lucide-react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { CheckIcon } from "lucide-react";
import { Checkbox as CheckboxPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "#/lib/utils.ts"
import { cn } from "#/lib/utils.ts";
function Checkbox({
className,
...props
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox }
export { Checkbox };
@@ -1,33 +1,33 @@
"use client"
"use client";
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
import { Collapsible as CollapsiblePrimitive } from "radix-ui";
function Collapsible({
...props
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({
...props
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
);
}
function CollapsibleContent({
...props
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
);
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+113 -114
View File
@@ -1,158 +1,157 @@
"use client"
"use client";
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "#/lib/utils.ts"
import { Button } from "#/components/ui/button.tsx"
import { XIcon } from "lucide-react";
import { Dialog as DialogPrimitive } from "radix-ui";
import type * as React from "react";
import { Button } from "#/components/ui/button.tsx";
import { cn } from "#/lib/utils.ts";
function Dialog({
...props
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({
className,
...props
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+92 -92
View File
@@ -1,133 +1,133 @@
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import type * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "#/lib/utils.ts"
import { cn } from "#/lib/utils.ts";
function Drawer({
...props
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
}
function DrawerTrigger({
...props
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
}
function DrawerPortal({
...props
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}
function DrawerClose({
...props
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}
function DrawerOverlay({
className,
...props
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
);
}
function DrawerContent({
className,
children,
...props
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content fixed z-50 flex h-auto flex-col bg-background",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full bg-muted group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content fixed z-50 flex h-auto flex-col bg-background",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className,
)}
{...props}
>
<div className="mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full bg-muted group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
)
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className,
)}
{...props}
/>
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function DrawerTitle({
className,
...props
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("font-semibold text-foreground", className)}
{...props}
/>
)
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("font-semibold text-foreground", className)}
{...props}
/>
);
}
function DrawerDescription({
className,
...props
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};
@@ -1,255 +1,255 @@
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "#/lib/utils.ts"
import { cn } from "#/lib/utils.ts";
function DropdownMenu({
...props
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
function DropdownMenuTrigger({
...props
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className,
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
)
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};
+198 -200
View File
@@ -1,246 +1,244 @@
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "#/lib/utils.ts"
import { Label } from "#/components/ui/label.tsx"
import { Separator } from "#/components/ui/separator.tsx"
import { cva, type VariantProps } from "class-variance-authority";
import { useMemo } from "react";
import { Label } from "#/components/ui/label.tsx";
import { Separator } from "#/components/ui/separator.tsx";
import { cn } from "#/lib/utils.ts";
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className,
)}
{...props}
/>
);
}
function FieldLegend({
className,
variant = "legend",
...props
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className
)}
{...props}
/>
)
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className,
)}
{...props}
/>
);
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className
)}
{...props}
/>
)
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className,
)}
{...props}
/>
);
}
const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
},
);
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
className,
orientation = "vertical",
...props
}: React.ComponentProps<"fieldset"> & VariantProps<typeof fieldVariants>) {
return (
<fieldset
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
);
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className
)}
{...props}
/>
)
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className,
)}
{...props}
/>
);
}
function FieldLabel({
className,
...props
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 dark:has-data-[state=checked]:bg-primary/10",
className
)}
{...props}
/>
)
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 dark:has-data-[state=checked]:bg-primary/10",
className,
)}
{...props}
/>
);
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className,
)}
{...props}
/>
);
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-sm leading-normal font-normal text-muted-foreground group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
return (
<p
data-slot="field-description"
className={cn(
"text-sm leading-normal font-normal text-muted-foreground group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className,
)}
{...props}
/>
);
}
function FieldSeparator({
children,
className,
...props
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
children?: React.ReactNode;
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className,
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
);
}
function FieldError({
className,
children,
errors,
...props
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
errors?: Array<{ message?: string } | undefined>;
}) {
const content = useMemo(() => {
if (children) {
return children
}
const content = useMemo(() => {
if (children) {
return children;
}
if (!errors?.length) {
return null
}
if (!errors?.length) {
return null;
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
];
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
if (uniqueErrors?.length === 1) {
return uniqueErrors[0]?.message;
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error) =>
error?.message && <li key={error.message}>{error.message}</li>,
)}
</ul>
);
}, [children, errors]);
if (!content) {
return null
}
if (!content) {
return null;
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
)
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
);
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
};
+16 -16
View File
@@ -1,21 +1,21 @@
import * as React from "react"
import type * as React from "react";
import { cn } from "#/lib/utils.ts"
import { cn } from "#/lib/utils.ts";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Input }
export { Input };
+16 -16
View File
@@ -1,22 +1,22 @@
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { Label as LabelPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "#/lib/utils.ts"
import { cn } from "#/lib/utils.ts";
function Label({
className,
...props
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label }
export { Label };
+144 -144
View File
@@ -1,188 +1,188 @@
import * as React from "react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { Select as SelectPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "#/lib/utils.ts"
import { cn } from "#/lib/utils.ts";
function Select({
...props
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
+21 -21
View File
@@ -1,28 +1,28 @@
"use client"
"use client";
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { Separator as SeparatorPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "#/lib/utils.ts"
import { cn } from "#/lib/utils.ts";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
);
}
export { Separator }
export { Separator };
+101 -101
View File
@@ -1,143 +1,143 @@
"use client"
"use client";
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { XIcon } from "lucide-react";
import { Dialog as SheetPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "#/lib/utils.ts"
import { cn } from "#/lib/utils.ts";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean;
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
side === "right" &&
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
side === "left" &&
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
side === "top" &&
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
side === "bottom" &&
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
side === "right" &&
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
side === "left" &&
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
side === "top" &&
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
side === "bottom" &&
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function SheetTitle({
className,
...props
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-semibold text-foreground", className)}
{...props}
/>
)
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-semibold text-foreground", className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};
File diff suppressed because it is too large Load Diff
@@ -1,13 +1,13 @@
import { cn } from "#/lib/utils.ts"
import { cn } from "#/lib/utils.ts";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-accent", className)}
{...props}
/>
)
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-accent", className)}
{...props}
/>
);
}
export { Skeleton }
export { Skeleton };
+33 -33
View File
@@ -1,38 +1,38 @@
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react";
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
);
};
export { Toaster }
export { Toaster };
@@ -1,46 +1,49 @@
import {Badge} from "#/components/ui/badge";
import {cn} from "#/lib/utils";
import { Badge } from "#/components/ui/badge";
import { cn } from "#/lib/utils";
type StatusBadgeProps = {
status: string;
label?: string;
status: string;
label?: string;
};
export function StatusBadge({status, label = status}: StatusBadgeProps) {
return (
<Badge variant="outline" className={cn("border font-semibold", statusColorClass(status))}>
{label}
</Badge>
);
export function StatusBadge({ status, label = status }: StatusBadgeProps) {
return (
<Badge
variant="outline"
className={cn("border font-semibold", statusColorClass(status))}
>
{label}
</Badge>
);
}
export function statusColorClass(status: string) {
switch (status) {
case "ACTIVE":
case "ENABLED":
case "IN_STOCK":
case "DELIVERED":
case "COMPLETED":
return "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/50 dark:text-emerald-300";
case "PENDING":
case "REQUESTED":
case "LOW_STOCK":
return "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900/60 dark:bg-amber-950/50 dark:text-amber-300";
case "SHIPPED":
case "ACCEPTED":
return "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/60 dark:bg-sky-950/50 dark:text-sky-300";
case "ADMIN":
return "border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-900/60 dark:bg-violet-950/50 dark:text-violet-300";
case "CUSTOMER":
return "border-cyan-200 bg-cyan-50 text-cyan-700 dark:border-cyan-900/60 dark:bg-cyan-950/50 dark:text-cyan-300";
case "DRIVER":
return "border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-900/60 dark:bg-indigo-950/50 dark:text-indigo-300";
case "CANCELLED":
case "INACTIVE":
case "DISABLED":
case "OUT_OF_STOCK":
return "border-red-200 bg-red-50 text-red-700 dark:border-red-900/60 dark:bg-red-950/50 dark:text-red-300";
default:
return "border-border bg-muted text-muted-foreground";
}
switch (status) {
case "ACTIVE":
case "ENABLED":
case "IN_STOCK":
case "DELIVERED":
case "COMPLETED":
return "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/50 dark:text-emerald-300";
case "PENDING":
case "REQUESTED":
case "LOW_STOCK":
return "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900/60 dark:bg-amber-950/50 dark:text-amber-300";
case "SHIPPED":
case "ACCEPTED":
return "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/60 dark:bg-sky-950/50 dark:text-sky-300";
case "ADMIN":
return "border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-900/60 dark:bg-violet-950/50 dark:text-violet-300";
case "CUSTOMER":
return "border-cyan-200 bg-cyan-50 text-cyan-700 dark:border-cyan-900/60 dark:bg-cyan-950/50 dark:text-cyan-300";
case "DRIVER":
return "border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-900/60 dark:bg-indigo-950/50 dark:text-indigo-300";
case "CANCELLED":
case "INACTIVE":
case "DISABLED":
case "OUT_OF_STOCK":
return "border-red-200 bg-red-50 text-red-700 dark:border-red-900/60 dark:bg-red-950/50 dark:text-red-300";
default:
return "border-border bg-muted text-muted-foreground";
}
}
+86 -86
View File
@@ -1,114 +1,114 @@
import * as React from "react"
import type * as React from "react";
import { cn } from "#/lib/utils.ts"
import { cn } from "#/lib/utils.ts";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className,
)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCaption({
className,
...props
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};
+70 -70
View File
@@ -1,91 +1,91 @@
"use client"
"use client";
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cva, type VariantProps } from "class-variance-authority";
import { Tabs as TabsPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "#/lib/utils.ts"
import { cn } from "#/lib/utils.ts";
function Tabs({
className,
orientation = "horizontal",
...props
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className
)}
{...props}
/>
)
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className,
)}
{...props}
/>
);
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
},
);
function TabsList({
className,
variant = "default",
...props
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className
)}
{...props}
/>
)
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className,
)}
{...props}
/>
);
}
function TabsContent({
className,
...props
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
@@ -1,83 +1,82 @@
"use client"
"use client";
import * as React from "react"
import { type VariantProps } from "class-variance-authority"
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"
import { cn } from "#/lib/utils.ts"
import { toggleVariants } from "#/components/ui/toggle.tsx"
import type { VariantProps } from "class-variance-authority";
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui";
import * as React from "react";
import { toggleVariants } from "#/components/ui/toggle.tsx";
import { cn } from "#/lib/utils.ts";
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
}
VariantProps<typeof toggleVariants> & {
spacing?: number;
}
>({
size: "default",
variant: "default",
spacing: 0,
})
size: "default",
variant: "default",
spacing: 0,
});
function ToggleGroup({
className,
variant,
size,
spacing = 0,
children,
...props
className,
variant,
size,
spacing = 0,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
VariantProps<typeof toggleVariants> & {
spacing?: number;
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
className,
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
);
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
}
export { ToggleGroup, ToggleGroupItem }
export { ToggleGroup, ToggleGroupItem };
+38 -38
View File
@@ -1,47 +1,47 @@
"use client"
"use client";
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Toggle as TogglePrimitive } from "radix-ui"
import { cva, type VariantProps } from "class-variance-authority";
import { Toggle as TogglePrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "#/lib/utils.ts"
import { cn } from "#/lib/utils.ts";
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 min-w-9 px-2",
sm: "h-8 min-w-8 px-1.5",
lg: "h-10 min-w-10 px-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 min-w-9 px-2",
sm: "h-8 min-w-8 px-1.5",
lg: "h-10 min-w-10 px-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Toggle({
className,
variant,
size,
...props
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Toggle, toggleVariants }
export { Toggle, toggleVariants };
+37 -37
View File
@@ -1,55 +1,55 @@
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { Tooltip as TooltipPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "#/lib/utils.ts"
import { cn } from "#/lib/utils.ts";
function TooltipProvider({
delayDuration = 0,
...props
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger({
...props
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+6 -6
View File
@@ -1,7 +1,7 @@
export function formatCurrency(amount: number, currency = "KES") {
return new Intl.NumberFormat("en-KE", {
style: "currency",
currency,
maximumFractionDigits: 2,
}).format(amount);
}
return new Intl.NumberFormat("en-KE", {
style: "currency",
currency,
maximumFractionDigits: 2,
}).format(amount);
}
+8 -8
View File
@@ -1,10 +1,10 @@
export function formatDate(value: string | null | undefined) {
if (!value) {
return "—";
}
if (!value) {
return "—";
}
return new Intl.DateTimeFormat("en-KE", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
return new Intl.DateTimeFormat("en-KE", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
+15 -13
View File
@@ -1,19 +1,21 @@
import * as React from "react"
import * as React from "react";
const MOBILE_BREAKPOINT = 768
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile
return !!isMobile;
}
+79 -79
View File
@@ -1,111 +1,111 @@
import {login, logout, register} from "#/api/auth";
import type {AuthUser, LoginRequest, RegisterRequest} from "#/types/auth";
import {useMutation, useQueryClient} from "@tanstack/react-query";
import {createContext, useContext, useState} from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createContext, useContext, useState } from "react";
import { login, logout, register } from "#/api/auth";
import type { AuthUser, LoginRequest, RegisterRequest } from "#/types/auth";
type AuthContextValue = {
user: AuthUser | null;
isLoading: boolean;
login: (request: LoginRequest) => Promise<AuthUser>;
register: (request: RegisterRequest) => Promise<AuthUser>;
logout: () => Promise<void>;
user: AuthUser | null;
isLoading: boolean;
login: (request: LoginRequest) => Promise<AuthUser>;
register: (request: RegisterRequest) => Promise<AuthUser>;
logout: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | null>(null);
const storedUserKey = "cymelle.auth.user";
export const authQueryKeys = {
me: ["auth", "me"] as const,
me: ["auth", "me"] as const,
};
export function AuthProvider({children}: {children: React.ReactNode}) {
const queryClient = useQueryClient();
const [user, setUser] = useState<AuthUser | null>(() => readStoredUser());
export function AuthProvider({ children }: { children: React.ReactNode }) {
const queryClient = useQueryClient();
const [user, setUser] = useState<AuthUser | null>(() => readStoredUser());
const loginMutation = useMutation({
mutationFn: login,
onSuccess: (user) => {
queryClient.clear();
persistUser(user);
setUser(user);
queryClient.setQueryData(authQueryKeys.me, user);
},
});
const loginMutation = useMutation({
mutationFn: login,
onSuccess: (user) => {
queryClient.clear();
persistUser(user);
setUser(user);
queryClient.setQueryData(authQueryKeys.me, user);
},
});
const registerMutation = useMutation({
mutationFn: register,
onSuccess: (user) => {
queryClient.clear();
persistUser(user);
setUser(user);
queryClient.setQueryData(authQueryKeys.me, user);
},
});
const registerMutation = useMutation({
mutationFn: register,
onSuccess: (user) => {
queryClient.clear();
persistUser(user);
setUser(user);
queryClient.setQueryData(authQueryKeys.me, user);
},
});
const logoutMutation = useMutation({
mutationFn: logout,
onSettled: () => {
persistUser(null);
setUser(null);
queryClient.clear();
},
});
const logoutMutation = useMutation({
mutationFn: logout,
onSettled: () => {
persistUser(null);
setUser(null);
queryClient.clear();
},
});
async function logoutUser() {
persistUser(null);
setUser(null);
queryClient.clear();
await logoutMutation.mutateAsync().catch(() => undefined);
}
async function logoutUser() {
persistUser(null);
setUser(null);
queryClient.clear();
await logoutMutation.mutateAsync().catch(() => undefined);
}
const value: AuthContextValue = {
user,
isLoading: false,
login: loginMutation.mutateAsync,
register: registerMutation.mutateAsync,
logout: logoutUser,
};
const value: AuthContextValue = {
user,
isLoading: false,
login: loginMutation.mutateAsync,
register: registerMutation.mutateAsync,
logout: logoutUser,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
return context;
}
function readStoredUser() {
if (typeof window === "undefined") {
return null;
}
if (typeof window === "undefined") {
return null;
}
const raw = window.localStorage.getItem(storedUserKey);
if (!raw) {
return null;
}
const raw = window.localStorage.getItem(storedUserKey);
if (!raw) {
return null;
}
try {
return JSON.parse(raw) as AuthUser;
} catch {
window.localStorage.removeItem(storedUserKey);
return null;
}
try {
return JSON.parse(raw) as AuthUser;
} catch {
window.localStorage.removeItem(storedUserKey);
return null;
}
}
function persistUser(user: AuthUser | null) {
if (typeof window === "undefined") {
return;
}
if (typeof window === "undefined") {
return;
}
if (!user) {
window.localStorage.removeItem(storedUserKey);
return;
}
if (!user) {
window.localStorage.removeItem(storedUserKey);
return;
}
window.localStorage.setItem(storedUserKey, JSON.stringify(user));
window.localStorage.setItem(storedUserKey, JSON.stringify(user));
}
+110 -91
View File
@@ -1,121 +1,140 @@
import type {Product} from "#/types/product";
import {createContext, useContext, useEffect, useMemo, useState} from "react";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import type { Product } from "#/types/product";
export type CartItem = {
product: Product;
quantity: number;
product: Product;
quantity: number;
};
type CartContextValue = {
items: CartItem[];
itemCount: number;
totalAmount: number;
currency: string;
addItem: (product: Product, quantity: number) => void;
updateQuantity: (productId: string, quantity: number) => void;
removeItem: (productId: string) => void;
clearCart: () => void;
items: CartItem[];
itemCount: number;
totalAmount: number;
currency: string;
addItem: (product: Product, quantity: number) => void;
updateQuantity: (productId: string, quantity: number) => void;
removeItem: (productId: string) => void;
clearCart: () => void;
};
const CartContext = createContext<CartContextValue | null>(null);
const CART_STORAGE_KEY = "cymelle_cart";
export function CartProvider({children}: {children: React.ReactNode}) {
const [items, setItems] = useState<CartItem[]>(() => {
if (typeof window === "undefined") {
return [];
}
export function CartProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = useState<CartItem[]>(() => {
if (typeof window === "undefined") {
return [];
}
try {
return JSON.parse(window.localStorage.getItem(CART_STORAGE_KEY) ?? "[]") as CartItem[];
} catch {
return [];
}
});
try {
return JSON.parse(
window.localStorage.getItem(CART_STORAGE_KEY) ?? "[]",
) as CartItem[];
} catch {
return [];
}
});
const itemCount = useMemo(
() => items.reduce((total, item) => total + item.quantity, 0),
[items],
);
const itemCount = useMemo(
() => items.reduce((total, item) => total + item.quantity, 0),
[items],
);
const totalAmount = useMemo(
() => items.reduce((total, item) => total + item.product.price * item.quantity, 0),
[items],
);
const totalAmount = useMemo(
() =>
items.reduce(
(total, item) => total + item.product.price * item.quantity,
0,
),
[items],
);
const currency = items[0]?.product.currency ?? "KES";
const currency = items[0]?.product.currency ?? "KES";
useEffect(() => {
window.localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(items));
}, [items]);
useEffect(() => {
window.localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(items));
}, [items]);
function addItem(product: Product, quantity: number) {
const safeQuantity = Math.min(Math.max(quantity, 1), product.availableQuantity);
function addItem(product: Product, quantity: number) {
const safeQuantity = Math.min(
Math.max(quantity, 1),
product.availableQuantity,
);
setItems((currentItems) => {
const existing = currentItems.find((item) => item.product.id === product.id);
setItems((currentItems) => {
const existing = currentItems.find(
(item) => item.product.id === product.id,
);
if (existing) {
return currentItems.map((item) =>
item.product.id === product.id
? {
...item,
product,
quantity: Math.min(item.quantity + safeQuantity, product.availableQuantity),
}
: item,
);
}
if (existing) {
return currentItems.map((item) =>
item.product.id === product.id
? {
...item,
product,
quantity: Math.min(
item.quantity + safeQuantity,
product.availableQuantity,
),
}
: item,
);
}
return [...currentItems, {product, quantity: safeQuantity}];
});
}
return [...currentItems, { product, quantity: safeQuantity }];
});
}
function updateQuantity(productId: string, quantity: number) {
setItems((currentItems) =>
currentItems.flatMap((item) => {
if (item.product.id !== productId) {
return [item];
}
function updateQuantity(productId: string, quantity: number) {
setItems((currentItems) =>
currentItems.flatMap((item) => {
if (item.product.id !== productId) {
return [item];
}
const nextQuantity = Math.min(Math.max(quantity, 0), item.product.availableQuantity);
return nextQuantity === 0 ? [] : [{...item, quantity: nextQuantity}];
}),
);
}
const nextQuantity = Math.min(
Math.max(quantity, 0),
item.product.availableQuantity,
);
return nextQuantity === 0 ? [] : [{ ...item, quantity: nextQuantity }];
}),
);
}
function removeItem(productId: string) {
setItems((currentItems) => currentItems.filter((item) => item.product.id !== productId));
}
function removeItem(productId: string) {
setItems((currentItems) =>
currentItems.filter((item) => item.product.id !== productId),
);
}
function clearCart() {
setItems([]);
}
function clearCart() {
setItems([]);
}
return (
<CartContext.Provider
value={{
items,
itemCount,
totalAmount,
currency,
addItem,
updateQuantity,
removeItem,
clearCart,
}}
>
{children}
</CartContext.Provider>
);
return (
<CartContext.Provider
value={{
items,
itemCount,
totalAmount,
currency,
addItem,
updateQuantity,
removeItem,
clearCart,
}}
>
{children}
</CartContext.Provider>
);
}
export function useCart() {
const context = useContext(CartContext);
const context = useContext(CartContext);
if (!context) {
throw new Error("useCart must be used within CartProvider");
}
if (!context) {
throw new Error("useCart must be used within CartProvider");
}
return context;
return context;
}
@@ -1,14 +1,14 @@
import {calculateFare} from "#/api/fare";
import {useMutation} from "@tanstack/react-query";
import { useMutation } from "@tanstack/react-query";
import { calculateFare } from "#/api/fare";
type FareCalculationInput = {
distanceKm: number;
surgeMultiplier?: number;
distanceKm: number;
surgeMultiplier?: number;
};
export function useFareCalculation() {
return useMutation({
mutationFn: ({distanceKm, surgeMultiplier}: FareCalculationInput) =>
calculateFare(distanceKm, surgeMultiplier),
});
}
return useMutation({
mutationFn: ({ distanceKm, surgeMultiplier }: FareCalculationInput) =>
calculateFare(distanceKm, surgeMultiplier),
});
}
+15 -15
View File
@@ -1,23 +1,23 @@
import {getInventory, getLowStockInventory} from "#/api/inventory";
import {useQuery} from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { getInventory, getLowStockInventory } from "#/api/inventory";
export const inventoryQueryKeys = {
all: ["inventory"] as const,
list: () => [...inventoryQueryKeys.all, "list"] as const,
lowStock: (threshold?: number) =>
[...inventoryQueryKeys.all, "low-stock", threshold ?? "default"] as const,
all: ["inventory"] as const,
list: () => [...inventoryQueryKeys.all, "list"] as const,
lowStock: (threshold?: number) =>
[...inventoryQueryKeys.all, "low-stock", threshold ?? "default"] as const,
};
export function useInventory() {
return useQuery({
queryKey: inventoryQueryKeys.list(),
queryFn: ({signal}) => getInventory(signal),
});
return useQuery({
queryKey: inventoryQueryKeys.list(),
queryFn: ({ signal }) => getInventory(signal),
});
}
export function useLowStockInventory(threshold?: number) {
return useQuery({
queryKey: inventoryQueryKeys.lowStock(threshold),
queryFn: ({signal}) => getLowStockInventory(threshold, signal),
});
}
return useQuery({
queryKey: inventoryQueryKeys.lowStock(threshold),
queryFn: ({ signal }) => getLowStockInventory(threshold, signal),
});
}
+57 -46
View File
@@ -1,64 +1,75 @@
import {cancelOrder, createOrder, getOrders, updateOrderStatus} from "#/api/orders";
import {inventoryQueryKeys} from "#/hooks/useInventory";
import {productQueryKeys} from "#/hooks/useProducts";
import type {OrderFilters} from "#/types/order";
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
cancelOrder,
createOrder,
getOrders,
updateOrderStatus,
} from "#/api/orders";
import { inventoryQueryKeys } from "#/hooks/useInventory";
import { productQueryKeys } from "#/hooks/useProducts";
import type { OrderFilters } from "#/types/order";
export const orderQueryKeys = {
all: ["orders"] as const,
list: (filters: OrderFilters, page = 0, size = 20) =>
[...orderQueryKeys.all, "list", filters, page, size] as const,
all: ["orders"] as const,
list: (filters: OrderFilters, page = 0, size = 20) =>
[...orderQueryKeys.all, "list", filters, page, size] as const,
};
export function useOrders(filters: OrderFilters, page = 0, size = 20) {
return useQuery({
queryKey: orderQueryKeys.list(filters, page, size),
queryFn: ({signal}) => getOrders(filters, page, size, signal),
});
return useQuery({
queryKey: orderQueryKeys.list(filters, page, size),
queryFn: ({ signal }) => getOrders(filters, page, size, signal),
});
}
export function useCancelOrder() {
const queryClient = useQueryClient();
const queryClient = useQueryClient();
return useMutation({
mutationFn: cancelOrder,
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: orderQueryKeys.all,
}),
queryClient.invalidateQueries({
queryKey: inventoryQueryKeys.all,
}),
queryClient.invalidateQueries({
queryKey: productQueryKeys.all,
}),
]);
},
});
return useMutation({
mutationFn: cancelOrder,
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: orderQueryKeys.all,
}),
queryClient.invalidateQueries({
queryKey: inventoryQueryKeys.all,
}),
queryClient.invalidateQueries({
queryKey: productQueryKeys.all,
}),
]);
},
});
}
export function useCreateOrder() {
const queryClient = useQueryClient();
const queryClient = useQueryClient();
return useMutation({
mutationFn: createOrder,
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({queryKey: orderQueryKeys.all}),
queryClient.invalidateQueries({queryKey: inventoryQueryKeys.all}),
queryClient.invalidateQueries({queryKey: productQueryKeys.all}),
]);
},
});
return useMutation({
mutationFn: createOrder,
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: orderQueryKeys.all }),
queryClient.invalidateQueries({ queryKey: inventoryQueryKeys.all }),
queryClient.invalidateQueries({ queryKey: productQueryKeys.all }),
]);
},
});
}
export function useUpdateOrderStatus() {
const queryClient = useQueryClient();
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({id, status}: {id: string; status: import("#/types/order").OrderStatus}) =>
updateOrderStatus(id, status),
onSuccess: () => queryClient.invalidateQueries({queryKey: orderQueryKeys.all}),
});
return useMutation({
mutationFn: ({
id,
status,
}: {
id: string;
status: import("#/types/order").OrderStatus;
}) => updateOrderStatus(id, status),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: orderQueryKeys.all }),
});
}
+61
View File
@@ -0,0 +1,61 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
getCheckoutQuote,
initializePaystackPayment,
verifyPaystackPayment,
} from "#/api/payments";
import { inventoryQueryKeys } from "#/hooks/useInventory";
import { orderQueryKeys } from "#/hooks/useOrders";
import { productQueryKeys } from "#/hooks/useProducts";
import type {
CheckoutQuoteRequest,
PaystackInitializeRequest,
} from "#/types/payment";
export const paymentQueryKeys = {
all: ["payments"] as const,
quote: (request: CheckoutQuoteRequest) =>
[...paymentQueryKeys.all, "quote", request] as const,
};
export function useCheckoutQuote(
request: CheckoutQuoteRequest,
enabled: boolean,
) {
return useQuery({
queryKey: paymentQueryKeys.quote(request),
queryFn: ({ signal }) => getCheckoutQuote(request, signal),
enabled,
});
}
export function useInitializePaystackPayment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (request: PaystackInitializeRequest) =>
initializePaystackPayment(request),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: orderQueryKeys.all }),
queryClient.invalidateQueries({ queryKey: inventoryQueryKeys.all }),
queryClient.invalidateQueries({ queryKey: productQueryKeys.all }),
]);
},
});
}
export function useVerifyPaystackPayment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: verifyPaystackPayment,
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: orderQueryKeys.all }),
queryClient.invalidateQueries({ queryKey: inventoryQueryKeys.all }),
queryClient.invalidateQueries({ queryKey: productQueryKeys.all }),
]);
},
});
}
+38 -29
View File
@@ -1,50 +1,59 @@
import {createProduct, deactivateProduct, getProduct, getProducts, updateProduct} from "#/api/products";
import type {ProductRequest} from "#/types/product";
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createProduct,
deactivateProduct,
getProduct,
getProducts,
updateProduct,
} from "#/api/products";
import type { ProductRequest } from "#/types/product";
export const productQueryKeys = {
all: ["products"] as const,
detail: (id: string) => [...productQueryKeys.all, "detail", id] as const,
all: ["products"] as const,
detail: (id: string) => [...productQueryKeys.all, "detail", id] as const,
};
export function useProducts() {
return useQuery({
queryKey: productQueryKeys.all,
queryFn: ({signal}) => getProducts(signal),
});
return useQuery({
queryKey: productQueryKeys.all,
queryFn: ({ signal }) => getProducts(signal),
});
}
export function useProduct(id: string) {
return useQuery({
queryKey: productQueryKeys.detail(id),
queryFn: ({signal}) => getProduct(id, signal),
});
return useQuery({
queryKey: productQueryKeys.detail(id),
queryFn: ({ signal }) => getProduct(id, signal),
});
}
export function useCreateProduct() {
const queryClient = useQueryClient();
const queryClient = useQueryClient();
return useMutation({
mutationFn: createProduct,
onSuccess: () => queryClient.invalidateQueries({queryKey: productQueryKeys.all}),
});
return useMutation({
mutationFn: createProduct,
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: productQueryKeys.all }),
});
}
export function useUpdateProduct() {
const queryClient = useQueryClient();
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({id, request}: {id: string; request: ProductRequest}) =>
updateProduct(id, request),
onSuccess: () => queryClient.invalidateQueries({queryKey: productQueryKeys.all}),
});
return useMutation({
mutationFn: ({ id, request }: { id: string; request: ProductRequest }) =>
updateProduct(id, request),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: productQueryKeys.all }),
});
}
export function useDeactivateProduct() {
const queryClient = useQueryClient();
const queryClient = useQueryClient();
return useMutation({
mutationFn: deactivateProduct,
onSuccess: () => queryClient.invalidateQueries({queryKey: productQueryKeys.all}),
});
return useMutation({
mutationFn: deactivateProduct,
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: productQueryKeys.all }),
});
}
+44 -39
View File
@@ -1,67 +1,72 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
acceptRide,
cancelRide,
completeRide,
getRides,
requestRide,
updateRideStatus,
acceptRide,
cancelRide,
completeRide,
getRides,
requestRide,
updateRideStatus,
} from "#/api/rides";
import type {CreateRideRequest, RideStatus} from "#/types/ride";
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query";
import type { CreateRideRequest, RideStatus } from "#/types/ride";
export const rideQueryKeys = {
all: ["rides"] as const,
all: ["rides"] as const,
};
export function useRides() {
return useQuery({
queryKey: rideQueryKeys.all,
queryFn: ({signal}) => getRides(signal),
});
return useQuery({
queryKey: rideQueryKeys.all,
queryFn: ({ signal }) => getRides(signal),
});
}
export function useRequestRide() {
const queryClient = useQueryClient();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (request: CreateRideRequest) => requestRide(request),
onSuccess: () => queryClient.invalidateQueries({queryKey: rideQueryKeys.all}),
});
return useMutation({
mutationFn: (request: CreateRideRequest) => requestRide(request),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: rideQueryKeys.all }),
});
}
export function useAcceptRide() {
const queryClient = useQueryClient();
const queryClient = useQueryClient();
return useMutation({
mutationFn: acceptRide,
onSuccess: () => queryClient.invalidateQueries({queryKey: rideQueryKeys.all}),
});
return useMutation({
mutationFn: acceptRide,
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: rideQueryKeys.all }),
});
}
export function useCompleteRide() {
const queryClient = useQueryClient();
const queryClient = useQueryClient();
return useMutation({
mutationFn: completeRide,
onSuccess: () => queryClient.invalidateQueries({queryKey: rideQueryKeys.all}),
});
return useMutation({
mutationFn: completeRide,
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: rideQueryKeys.all }),
});
}
export function useCancelRide() {
const queryClient = useQueryClient();
const queryClient = useQueryClient();
return useMutation({
mutationFn: cancelRide,
onSuccess: () => queryClient.invalidateQueries({queryKey: rideQueryKeys.all}),
});
return useMutation({
mutationFn: cancelRide,
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: rideQueryKeys.all }),
});
}
export function useUpdateRideStatus() {
const queryClient = useQueryClient();
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({id, status}: {id: string; status: RideStatus}) =>
updateRideStatus(id, status),
onSuccess: () => queryClient.invalidateQueries({queryKey: rideQueryKeys.all}),
});
return useMutation({
mutationFn: ({ id, status }: { id: string; status: RideStatus }) =>
updateRideStatus(id, status),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: rideQueryKeys.all }),
});
}

Some files were not shown because too many files have changed in this diff Show More