mirror of
https://github.com/andwati/cymelle.git
synced 2026-10-23 18:32:15 +00:00
paystack
This commit is contained in:
@@ -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
|
||||
Generated
+1
@@ -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>
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
@@ -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:
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+51
@@ -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
|
||||
) {
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
}
|
||||
+13
@@ -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
|
||||
) {
|
||||
}
|
||||
+18
@@ -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
|
||||
) {
|
||||
}
|
||||
+17
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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'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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 === "/");
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user