commit 55d97722724267a06bb28463a497c83e48ea716c Author: MangoPig Date: Tue Jun 23 18:50:37 2026 +0100 Version 1 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bf8a901 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.DS_Store +Store/node_modules +Store/dist +Backend/tmp +Backend/Backups diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..54dd538 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# build output +dist/ +# generated types +.astro/ + +# dependencies +node_modules/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + + +# environment variables +.env +.env.production +.env.local + +# local secrets / runtime files +Backend/Backups/ +Backend/tmp/ +Env/*.crt +Env/*.key + +# macOS-specific files +.DS_Store + +# jetbrains setting folder +.idea/ + +origin.crt +origin.key diff --git a/Backend/.air.api.toml b/Backend/.air.api.toml new file mode 100644 index 0000000..58cbb91 --- /dev/null +++ b/Backend/.air.api.toml @@ -0,0 +1,12 @@ +root = "." +tmp_dir = "tmp/dev" + +[build] +cmd = "go build -o ./tmp/dev/bin/api ./cmd/api" +bin = "./tmp/dev/bin/api" +include_ext = ["go"] +exclude_dir = ["tmp", "vendor"] +delay = 250 + +[log] +time = true diff --git a/Backend/.dockerignore b/Backend/.dockerignore new file mode 100644 index 0000000..6971dbc --- /dev/null +++ b/Backend/.dockerignore @@ -0,0 +1,3 @@ +.git +.DS_Store +tmp diff --git a/Backend/Dockerfile b/Backend/Dockerfile new file mode 100644 index 0000000..456dc31 --- /dev/null +++ b/Backend/Dockerfile @@ -0,0 +1,34 @@ +# syntax=docker/dockerfile:1.7 + +FROM golang:1.25.7-alpine AS base + +WORKDIR /app + +RUN apk add --no-cache ca-certificates curl git tzdata && update-ca-certificates + +COPY go.mod go.sum ./ +RUN go mod download + +FROM base AS development + +RUN curl -fsSL https://raw.githubusercontent.com/air-verse/air/master/install.sh | sh -s -- -b /usr/local/bin + +COPY . . + +CMD ["air", "-c", ".air.api.toml"] + +FROM base AS builder + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/api ./cmd/api + +FROM alpine:3.22 AS runtime + +RUN apk add --no-cache ca-certificates tzdata && update-ca-certificates + +WORKDIR /app + +COPY --from=builder /out/api /usr/local/bin/api + +CMD ["/usr/local/bin/api"] diff --git a/Backend/cmd/api/main.go b/Backend/cmd/api/main.go new file mode 100644 index 0000000..05201e9 --- /dev/null +++ b/Backend/cmd/api/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "context" + "log" + "net/http" + "time" + + "royal-pop-backend/internal/config" + "royal-pop-backend/internal/database" + "royal-pop-backend/internal/httpx" + "royal-pop-backend/internal/inventory" + "royal-pop-backend/internal/mailer" + "royal-pop-backend/internal/orders" +) + +func main() { + cfg := config.Load() + db, err := database.NewPostgres(cfg.DatabaseURL) + if err != nil { + log.Fatalf("connect postgres: %v", err) + } + defer db.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := db.EnsureSchema(ctx); err != nil { + log.Fatalf("ensure postgres schema: %v", err) + } + + handler := httpx.NewRouter(httpx.RouterConfig{ + Config: cfg, + DB: db, + Orders: orders.NewStore(db), + Stock: inventory.NewStore(db), + Mailer: mailer.NewResendMailer(cfg), + }) + + log.Printf("royal-pop api listening on %s", cfg.Address()) + if err := http.ListenAndServe(cfg.Address(), handler); err != nil { + log.Fatal(err) + } +} diff --git a/Backend/docker-bake.hcl b/Backend/docker-bake.hcl new file mode 100644 index 0000000..350baa4 --- /dev/null +++ b/Backend/docker-bake.hcl @@ -0,0 +1,50 @@ +variable "REGISTRY" { + default = "registry.mangopig.tech" +} + +variable "TAG" { + default = "latest" +} + +target "_app" { + context = "." + dockerfile = "Dockerfile" +} + +target "dev" { + inherits = ["_app"] + target = "development" + tags = ["goko/royal-pop/api:dev"] +} + +target "prod" { + inherits = ["_app"] + target = "runtime" + tags = ["goko/royal-pop/api:local-prod"] +} + +target "dev-image" { + inherits = ["_app"] + target = "development" + tags = ["${REGISTRY}/goko/royal-pop/api/dev:${TAG}"] + output = ["type=registry"] +} + +target "prod-image" { + inherits = ["_app"] + target = "runtime" + tags = ["${REGISTRY}/goko/royal-pop/api/prod:${TAG}"] + output = ["type=registry"] +} + +group "local" { + targets = ["dev", "prod"] +} + +group "registry" { + targets = ["dev-image", "prod-image"] +} + +group "default" { + targets = ["dev"] +} diff --git a/Backend/go.mod b/Backend/go.mod new file mode 100644 index 0000000..8a49aae --- /dev/null +++ b/Backend/go.mod @@ -0,0 +1,18 @@ +module royal-pop-backend + +go 1.25.0 + +require ( + github.com/go-chi/chi/v5 v5.2.3 + github.com/jackc/pgx/v5 v5.7.5 + github.com/stripe/stripe-go/v83 v83.1.0 +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/text v0.24.0 // indirect +) diff --git a/Backend/go.sum b/Backend/go.sum new file mode 100644 index 0000000..6ca2f36 --- /dev/null +++ b/Backend/go.sum @@ -0,0 +1,34 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= +github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stripe/stripe-go/v83 v83.1.0 h1:h6Wi8+dSUCmIdXDWObs1AirP9tQGWWI/4xP5oE5G6uQ= +github.com/stripe/stripe-go/v83 v83.1.0/go.mod h1:nRyDcLrJtwPPQUnKAFs9Bt1NnQvNhNiF6V19XHmPISE= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/Backend/internal/config/config.go b/Backend/internal/config/config.go new file mode 100644 index 0000000..79b7c3e --- /dev/null +++ b/Backend/internal/config/config.go @@ -0,0 +1,154 @@ +package config + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" +) + +type Config struct { + AppName string + Environment string + APIPort string + DatabaseURL string + StorefrontURL string + AllowedOrigins []string + ResendAPIKey string + ResendOrderFrom string + ResendForward string + ClientDashboardUser string + ClientDashboardPass string + ClientDashboardSecret string + ClientDashboardSessionTTL time.Duration + StripeSecretKey string + StripeWebhookSecret string + StripePriceID string + StripeCurrency string + RoyalPopUnitAmount int64 + RoyalPopRetailAmount int64 + ShutdownTimeout time.Duration +} + +func Load() *Config { + environment := getEnv("GO_ENV", "development") + storefrontURL := getEnv("API_ALLOWED_ORIGIN", getEnv("STOREFRONT_URL", "http://localhost:4321")) + clientDashboardUser := getEnv("CLIENT_DASHBOARD_USERNAME", "") + clientDashboardPass := getEnv("CLIENT_DASHBOARD_PASSWORD", "") + + if strings.EqualFold(environment, "development") { + if strings.TrimSpace(clientDashboardUser) == "" { + clientDashboardUser = "client" + } + if strings.TrimSpace(clientDashboardPass) == "" { + clientDashboardPass = "royalpop-dev" + } + } + + return &Config{ + AppName: getEnv("APP_NAME", "royal-pop"), + Environment: environment, + APIPort: getEnv("BACKEND_API_PORT", "8081"), + DatabaseURL: getEnv("DATABASE_URL", "postgres://royalpop:royalpop_dev_password@localhost:5432/royalpop?sslmode=disable"), + StorefrontURL: storefrontURL, + AllowedOrigins: splitCSV(getEnv("API_ALLOWED_ORIGINS", storefrontURL)), + ResendAPIKey: getEnv("RESEND_API_KEY", ""), + ResendOrderFrom: getEnv("RESEND_ORDER_FROM", "orders@royal-pop-accessory.com"), + ResendForward: getEnv("RESEND_FORWARD", ""), + ClientDashboardUser: clientDashboardUser, + ClientDashboardPass: clientDashboardPass, + ClientDashboardSecret: getEnv("CLIENT_DASHBOARD_SESSION_SECRET", ""), + ClientDashboardSessionTTL: getDurationEnv("CLIENT_DASHBOARD_SESSION_TTL", 12*time.Hour), + StripeSecretKey: getEnv("STRIPE_SECRET_KEY", ""), + StripeWebhookSecret: getEnv("STRIPE_WEBHOOK_SECRET", ""), + StripePriceID: getEnv("STRIPE_PRICE_ID", "price_1TkPnvCpoCwKMSycHiQBPVms"), + StripeCurrency: strings.ToLower(getEnv("STRIPE_CURRENCY", "gbp")), + RoyalPopUnitAmount: getInt64Env("ROYAL_POP_UNIT_AMOUNT", 4999), + RoyalPopRetailAmount: getInt64Env("ROYAL_POP_RETAIL_AMOUNT", 8999), + ShutdownTimeout: getDurationEnv("BACKEND_SHUTDOWN_TIMEOUT", 10*time.Second), + } +} + +func (c *Config) Address() string { + return fmt.Sprintf(":%s", c.APIPort) +} + +func (c *Config) IsDevelopment() bool { + return strings.EqualFold(c.Environment, "development") +} + +func (c *Config) ClientDashboardEnabled() bool { + if c == nil { + return false + } + + return strings.TrimSpace(c.ClientDashboardUser) != "" && strings.TrimSpace(c.ClientDashboardPass) != "" +} + +func (c *Config) ClientDashboardSigningSecret() string { + if c == nil || !c.ClientDashboardEnabled() { + return "" + } + + if secret := strings.TrimSpace(c.ClientDashboardSecret); secret != "" { + return secret + } + + return strings.Join([]string{c.AppName, c.ClientDashboardUser, c.ClientDashboardPass}, "|") +} + +func getEnv(key, fallback string) string { + if value, exists := os.LookupEnv(key); exists && strings.TrimSpace(value) != "" { + return value + } + + return fallback +} + +func getDurationEnv(key string, fallback time.Duration) time.Duration { + value, exists := os.LookupEnv(key) + if !exists { + return fallback + } + + parsed, err := time.ParseDuration(value) + if err != nil { + return fallback + } + + return parsed +} + +func getInt64Env(key string, fallback int64) int64 { + value, exists := os.LookupEnv(key) + if !exists || strings.TrimSpace(value) == "" { + return fallback + } + + parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + if err != nil { + return fallback + } + + return parsed +} + +func splitCSV(raw string) []string { + parts := strings.Split(raw, ",") + values := make([]string, 0, len(parts)) + + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed == "" { + continue + } + values = append(values, trimmed) + } + + if len(values) == 0 { + return []string{"*"} + } + + return values +} diff --git a/Backend/internal/database/postgres.go b/Backend/internal/database/postgres.go new file mode 100644 index 0000000..8011aea --- /dev/null +++ b/Backend/internal/database/postgres.go @@ -0,0 +1,134 @@ +package database + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type DB struct { + Pool *pgxpool.Pool +} + +func NewPostgres(databaseURL string) (*DB, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + config, err := pgxpool.ParseConfig(databaseURL) + if err != nil { + return nil, err + } + + config.MaxConns = 10 + config.MinConns = 1 + config.MaxConnLifetime = time.Hour + config.MaxConnIdleTime = 30 * time.Minute + + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + return nil, err + } + + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, err + } + + return &DB{Pool: pool}, nil +} + +func (d *DB) EnsureSchema(ctx context.Context) error { + if d == nil || d.Pool == nil { + return nil + } + + statements := []string{ + `CREATE TABLE IF NOT EXISTS orders ( + id TEXT PRIMARY KEY, + status TEXT NOT NULL, + fulfillment_status TEXT NOT NULL DEFAULT 'pending', + email TEXT NOT NULL, + phone TEXT NOT NULL, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + address_line_1 TEXT NOT NULL, + address_line_2 TEXT NOT NULL DEFAULT '', + city TEXT NOT NULL, + region TEXT NOT NULL, + postal_code TEXT NOT NULL, + country TEXT NOT NULL, + notes TEXT NOT NULL DEFAULT '', + currency TEXT NOT NULL, + amount BIGINT NOT NULL, + stripe_price_id TEXT NOT NULL, + stripe_payment_intent_id TEXT NOT NULL UNIQUE, + shipping_carrier TEXT NOT NULL DEFAULT '', + tracking_number TEXT NOT NULL DEFAULT '', + fulfillment_notes TEXT NOT NULL DEFAULT '', + shipped_at TIMESTAMPTZ NULL, + webhook_status TEXT NOT NULL DEFAULT 'awaiting_webhook', + webhook_event_id TEXT NOT NULL DEFAULT '', + webhook_event_type TEXT NOT NULL DEFAULT '', + webhook_message TEXT NOT NULL DEFAULT 'Waiting for Stripe webhook verification.', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`, + `ALTER TABLE orders ADD COLUMN IF NOT EXISTS fulfillment_status TEXT NOT NULL DEFAULT 'pending'`, + `ALTER TABLE orders ADD COLUMN IF NOT EXISTS shipping_carrier TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE orders ADD COLUMN IF NOT EXISTS tracking_number TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE orders ADD COLUMN IF NOT EXISTS fulfillment_notes TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE orders ADD COLUMN IF NOT EXISTS shipped_at TIMESTAMPTZ NULL`, + `CREATE TABLE IF NOT EXISTS order_items ( + id BIGSERIAL PRIMARY KEY, + order_id TEXT NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + item_id TEXT NOT NULL, + style TEXT NOT NULL, + colorway_id TEXT NOT NULL, + finish_id TEXT NOT NULL, + quantity BIGINT NOT NULL, + unit_amount BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`, + `CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items(order_id)`, + `CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status)`, + `CREATE INDEX IF NOT EXISTS idx_orders_fulfillment_status ON orders(fulfillment_status)`, + `CREATE INDEX IF NOT EXISTS idx_orders_tracking_number ON orders(tracking_number)`, + `CREATE TABLE IF NOT EXISTS inventory_levels ( + id BIGSERIAL PRIMARY KEY, + style TEXT NOT NULL, + colorway_id TEXT NOT NULL, + finish_id TEXT NOT NULL, + quantity_on_hand BIGINT NOT NULL DEFAULT 0, + notes TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(style, colorway_id, finish_id) + )`, + `CREATE INDEX IF NOT EXISTS idx_inventory_levels_colorway ON inventory_levels(colorway_id)`, + } + + for _, statement := range statements { + if _, err := d.Pool.Exec(ctx, statement); err != nil { + return err + } + } + + return nil +} + +func (d *DB) Health(ctx context.Context) error { + if d == nil || d.Pool == nil { + return nil + } + + return d.Pool.Ping(ctx) +} + +func (d *DB) Close() { + if d == nil || d.Pool == nil { + return + } + + d.Pool.Close() +} diff --git a/Backend/internal/database/reseed.go b/Backend/internal/database/reseed.go new file mode 100644 index 0000000..57bbdf8 --- /dev/null +++ b/Backend/internal/database/reseed.go @@ -0,0 +1,164 @@ +package database + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/jackc/pgx/v5" +) + +type ReseedSummary struct { + BackupPath string + OrdersCount int + OrderItemsCount int + InventoryCount int +} + +func (d *DB) BackupAndResetAppData(ctx context.Context) (*ReseedSummary, error) { + if d == nil || d.Pool == nil { + return nil, fmt.Errorf("database is not configured") + } + + tx, err := d.Pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead}) + if err != nil { + return nil, err + } + + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + + ordersStatements, err := collectStatements(ctx, tx, ` + SELECT format( + 'INSERT INTO orders (id, status, fulfillment_status, email, phone, first_name, last_name, address_line_1, address_line_2, city, region, postal_code, country, notes, currency, amount, stripe_price_id, stripe_payment_intent_id, shipping_carrier, tracking_number, fulfillment_notes, shipped_at, webhook_status, webhook_event_id, webhook_event_type, webhook_message, created_at, updated_at) VALUES (%L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L, %L);', + id, status, fulfillment_status, email, phone, first_name, last_name, address_line_1, address_line_2, city, region, postal_code, country, notes, currency, amount, stripe_price_id, stripe_payment_intent_id, shipping_carrier, tracking_number, fulfillment_notes, shipped_at, webhook_status, webhook_event_id, webhook_event_type, webhook_message, created_at, updated_at + ) + FROM orders + ORDER BY created_at ASC, id ASC + `) + if err != nil { + return nil, err + } + + orderItemsStatements, err := collectStatements(ctx, tx, ` + SELECT format( + 'INSERT INTO order_items (order_id, item_id, style, colorway_id, finish_id, quantity, unit_amount, created_at) VALUES (%L, %L, %L, %L, %L, %L, %L, %L);', + order_id, item_id, style, colorway_id, finish_id, quantity, unit_amount, created_at + ) + FROM order_items + ORDER BY created_at ASC, order_id ASC + `) + if err != nil { + return nil, err + } + + inventoryStatements, err := collectStatements(ctx, tx, ` + SELECT format( + 'INSERT INTO inventory_levels (style, colorway_id, finish_id, quantity_on_hand, notes, created_at, updated_at) VALUES (%L, %L, %L, %L, %L, %L, %L);', + style, colorway_id, finish_id, quantity_on_hand, notes, created_at, updated_at + ) + FROM inventory_levels + ORDER BY style ASC, colorway_id ASC, finish_id ASC + `) + if err != nil { + return nil, err + } + + backupContent := buildReseedBackupSQL(ordersStatements, orderItemsStatements, inventoryStatements) + backupPath, err := writeReseedBackupFile(backupContent) + if err != nil { + return nil, err + } + + if _, err := tx.Exec(ctx, `TRUNCATE TABLE order_items, orders, inventory_levels RESTART IDENTITY CASCADE`); err != nil { + return nil, err + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + committed = true + + return &ReseedSummary{ + BackupPath: backupPath, + OrdersCount: len(ordersStatements), + OrderItemsCount: len(orderItemsStatements), + InventoryCount: len(inventoryStatements), + }, nil +} + +func collectStatements(ctx context.Context, tx pgx.Tx, query string) ([]string, error) { + rows, err := tx.Query(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + statements := make([]string, 0) + for rows.Next() { + var statement string + if err := rows.Scan(&statement); err != nil { + return nil, err + } + statements = append(statements, statement) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return statements, nil +} + +func buildReseedBackupSQL(ordersStatements, orderItemsStatements, inventoryStatements []string) []byte { + var buffer bytes.Buffer + timestamp := time.Now().UTC().Format(time.RFC3339) + + buffer.WriteString("-- Royal Pop local reseed backup\n") + buffer.WriteString("-- Created at: " + timestamp + "\n") + buffer.WriteString(fmt.Sprintf("-- Orders: %d | Order items: %d | Inventory rows: %d\n\n", len(ordersStatements), len(orderItemsStatements), len(inventoryStatements))) + buffer.WriteString("BEGIN;\n") + buffer.WriteString("TRUNCATE TABLE order_items, orders, inventory_levels RESTART IDENTITY CASCADE;\n\n") + + appendSection := func(title string, statements []string) { + buffer.WriteString("-- " + title + "\n") + if len(statements) == 0 { + buffer.WriteString("-- (no rows)\n\n") + return + } + for _, statement := range statements { + buffer.WriteString(statement) + buffer.WriteString("\n") + } + buffer.WriteString("\n") + } + + appendSection("orders", ordersStatements) + appendSection("order_items", orderItemsStatements) + appendSection("inventory_levels", inventoryStatements) + buffer.WriteString("COMMIT;\n") + + return buffer.Bytes() +} + +func writeReseedBackupFile(content []byte) (string, error) { + backupDir := filepath.Join("Backups", "reseed") + if err := os.MkdirAll(backupDir, 0o755); err != nil { + return "", err + } + + filename := fmt.Sprintf("royal-pop-reseed-%s.sql", time.Now().UTC().Format("20060102-150405")) + path := filepath.Join(backupDir, filename) + if err := os.WriteFile(path, content, 0o600); err != nil { + return "", err + } + + return path, nil +} diff --git a/Backend/internal/httpx/checkout_routes.go b/Backend/internal/httpx/checkout_routes.go new file mode 100644 index 0000000..f9f5bbe --- /dev/null +++ b/Backend/internal/httpx/checkout_routes.go @@ -0,0 +1,446 @@ +package httpx + +import ( + "encoding/json" + "fmt" + "net/http" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/stripe/stripe-go/v83" + "github.com/stripe/stripe-go/v83/paymentintent" + stripeprice "github.com/stripe/stripe-go/v83/price" + + "royal-pop-backend/internal/config" + "royal-pop-backend/internal/orders" +) + +type createPaymentIntentRequest struct { + Items []checkoutItem `json:"items"` + PaymentMethod string `json:"paymentMethod"` + Customer checkoutCustomerDetails `json:"customer"` +} + +type checkoutItem struct { + ID string `json:"id"` + Style string `json:"style"` + ColorwayID string `json:"colorwayId"` + FinishID string `json:"finishId"` + Quantity int64 `json:"quantity,omitempty"` +} + +type checkoutCustomerDetails struct { + Email string `json:"email"` + Phone string `json:"phone"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + AddressLine1 string `json:"addressLine1"` + AddressLine2 string `json:"addressLine2"` + City string `json:"city"` + Region string `json:"region"` + PostalCode string `json:"postalCode"` + Country string `json:"country"` + Notes string `json:"notes"` +} + +type storefrontPricing struct { + PriceID string + Currency string + UnitAmount int64 + RetailAmount int64 +} + +var emailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`) + +func handleCreatePaymentIntent(cfg *config.Config, orderStore *orders.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if cfg.StripeSecretKey == "" { + WriteError(w, http.StatusInternalServerError, "stripe_not_configured", "Missing STRIPE_SECRET_KEY for backend Stripe calls.") + return + } + + var request createPaymentIntentRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + WriteError(w, http.StatusBadRequest, "invalid_request", "The payment intent payload could not be parsed.") + return + } + + itemCount := countItems(request.Items) + if itemCount <= 0 { + WriteError(w, http.StatusBadRequest, "empty_cart", "Add at least one Royal Pop kit before creating a payment intent.") + return + } + + customerDetails, err := validateCustomerDetails(request.Customer) + if err != nil { + WriteError(w, http.StatusBadRequest, "invalid_customer_details", err.Error()) + return + } + + pricing := resolveStorefrontPricing(cfg) + stripe.Key = cfg.StripeSecretKey + amount := itemCount * pricing.UnitAmount + orderID := orders.NewOrderID() + metadata := buildPaymentIntentMetadata(cfg, request, itemCount, amount, orderID) + + params := &stripe.PaymentIntentParams{ + Amount: stripe.Int64(amount), + Currency: stripe.String(pricing.Currency), + AutomaticPaymentMethods: &stripe.PaymentIntentAutomaticPaymentMethodsParams{ + Enabled: stripe.Bool(true), + AllowRedirects: stripe.String("never"), + }, + Description: stripe.String(fmt.Sprintf("Royal Pop preorder (%d kit%s)", itemCount, pluralize(itemCount))), + } + + for key, value := range metadata { + params.AddMetadata(key, value) + } + + intent, err := paymentintent.New(params) + if err != nil { + WriteError(w, http.StatusBadGateway, "stripe_payment_intent_failed", err.Error()) + return + } + + orderRecord, err := orderStore.CreatePendingOrder(r.Context(), orders.CreateOrderInput{ + OrderID: orderID, + Customer: orders.CustomerDetails{ + Email: customerDetails.Email, + Phone: customerDetails.Phone, + FirstName: customerDetails.FirstName, + LastName: customerDetails.LastName, + AddressLine1: customerDetails.AddressLine1, + AddressLine2: customerDetails.AddressLine2, + City: customerDetails.City, + Region: customerDetails.Region, + PostalCode: customerDetails.PostalCode, + Country: customerDetails.Country, + Notes: customerDetails.Notes, + }, + Items: buildOrderItems(request.Items, pricing.UnitAmount), + Currency: pricing.Currency, + Amount: amount, + StripePriceID: cfg.StripePriceID, + StripePaymentIntentID: intent.ID, + }) + if err != nil { + WriteError(w, http.StatusInternalServerError, "order_persist_failed", "The payment session was created, but the order could not be saved. Please try again.") + return + } + + paymentIntentStates.upsertCreated(intent.ID) + + WriteJSON(w, http.StatusCreated, map[string]any{ + "data": map[string]any{ + "orderId": orderRecord.ID, + "paymentIntentId": intent.ID, + "clientSecret": intent.ClientSecret, + "amount": intent.Amount, + "currency": intent.Currency, + "itemCount": itemCount, + "priceId": cfg.StripePriceID, + "unitAmount": pricing.UnitAmount, + "submitEnabled": true, + "message": paymentIntentCreatedMessage(cfg), + }, + }) + } +} + +func handleGetStorefrontPricing(cfg *config.Config) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + pricing := resolveStorefrontPricing(cfg) + + WriteJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "priceId": pricing.PriceID, + "currency": pricing.Currency, + "unitAmount": pricing.UnitAmount, + "retailAmount": pricing.RetailAmount, + }, + }) + } +} + +func handleGetPaymentIntentStatus(cfg *config.Config, orderStore *orders.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if cfg.StripeSecretKey == "" { + WriteError(w, http.StatusInternalServerError, "stripe_not_configured", "Missing STRIPE_SECRET_KEY for backend Stripe calls.") + return + } + + paymentIntentID := strings.TrimSpace(chi.URLParam(r, "paymentIntentID")) + if paymentIntentID == "" { + WriteError(w, http.StatusBadRequest, "missing_payment_intent_id", "The payment intent ID is required.") + return + } + + stripe.Key = cfg.StripeSecretKey + intent, err := paymentintent.Get(paymentIntentID, nil) + if err != nil { + WriteError(w, http.StatusBadGateway, "stripe_payment_intent_lookup_failed", err.Error()) + return + } + + verification, exists := paymentIntentStates.get(intent.ID) + orderRecord, orderErr := orderStore.GetByPaymentIntentID(r.Context(), intent.ID) + if orderErr == nil { + verification = paymentIntentVerification{ + PaymentIntentID: orderRecord.StripePaymentIntentID, + WebhookState: orderRecord.WebhookStatus, + WebhookEventID: orderRecord.WebhookEventID, + WebhookEventType: orderRecord.WebhookEventType, + Message: orderRecord.WebhookMessage, + Verified: orderRecord.Status == orders.StatusPaid, + } + exists = true + } + if !exists { + verification = paymentIntentVerification{ + PaymentIntentID: intent.ID, + WebhookState: "awaiting_webhook", + Message: "Waiting for Stripe webhook verification.", + } + } + + message := verification.Message + if message == "" { + message = defaultStatusMessage(intent.Status, verification.Verified) + } + + WriteJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "orderId": orderIDFromOrder(orderRecord), + "orderStatus": orderStatusFromOrder(orderRecord), + "order": serializeOrder(orderRecord), + "paymentIntentId": intent.ID, + "stripeStatus": string(intent.Status), + "amount": intent.Amount, + "currency": intent.Currency, + "webhookStatus": verification.WebhookState, + "webhookEventId": verification.WebhookEventID, + "webhookEventType": verification.WebhookEventType, + "verified": verification.Verified, + "message": message, + }, + }) + } +} + +func countItems(items []checkoutItem) int64 { + var total int64 + for _, item := range items { + quantity := item.Quantity + if quantity <= 0 { + quantity = 1 + } + total += quantity + } + return total +} + +func buildPaymentIntentMetadata(cfg *config.Config, request createPaymentIntentRequest, itemCount int64, amount int64, orderID string) map[string]string { + pricing := resolveStorefrontPricing(cfg) + colorways := make([]string, 0, len(request.Items)) + styles := make([]string, 0, len(request.Items)) + + for _, item := range request.Items { + if item.ColorwayID != "" { + colorways = append(colorways, item.ColorwayID) + } + if item.Style != "" { + styles = append(styles, item.Style) + } + } + + sort.Strings(colorways) + sort.Strings(styles) + + return map[string]string{ + "order_id": orderID, + "source": "royal-pop-storefront", + "stripe_price_id": cfg.StripePriceID, + "payment_method_ui": strings.TrimSpace(request.PaymentMethod), + "item_count": fmt.Sprintf("%d", itemCount), + "unit_amount": fmt.Sprintf("%d", pricing.UnitAmount), + "amount_total": fmt.Sprintf("%d", amount), + "colorways": strings.Join(colorways, ","), + "styles": strings.Join(styles, ","), + } +} + +func resolveStorefrontPricing(cfg *config.Config) storefrontPricing { + pricing := storefrontPricing{ + PriceID: strings.TrimSpace(cfg.StripePriceID), + Currency: strings.ToLower(strings.TrimSpace(cfg.StripeCurrency)), + UnitAmount: cfg.RoyalPopUnitAmount, + RetailAmount: cfg.RoyalPopRetailAmount, + } + + if pricing.Currency == "" { + pricing.Currency = "gbp" + } + + if strings.TrimSpace(cfg.StripeSecretKey) == "" || pricing.PriceID == "" { + return pricing + } + + stripe.Key = cfg.StripeSecretKey + priceRecord, err := stripeprice.Get(pricing.PriceID, nil) + if err != nil || priceRecord == nil { + return pricing + } + + if priceRecord.UnitAmount > 0 { + pricing.UnitAmount = priceRecord.UnitAmount + } + + if currency := strings.ToLower(string(priceRecord.Currency)); currency != "" { + pricing.Currency = currency + } + + for _, key := range []string{"retail_amount", "compare_at_amount", "compareAtAmount"} { + if raw := strings.TrimSpace(priceRecord.Metadata[key]); raw != "" { + if parsed, err := strconv.ParseInt(raw, 10, 64); err == nil && parsed > 0 { + pricing.RetailAmount = parsed + break + } + } + } + + return pricing +} + +func buildOrderItems(items []checkoutItem, unitAmount int64) []orders.LineItem { + result := make([]orders.LineItem, 0, len(items)) + for _, item := range items { + quantity := item.Quantity + if quantity <= 0 { + quantity = 1 + } + result = append(result, orders.LineItem{ + ItemID: strings.TrimSpace(item.ID), + Style: strings.TrimSpace(item.Style), + ColorwayID: strings.TrimSpace(item.ColorwayID), + FinishID: strings.TrimSpace(item.FinishID), + Quantity: quantity, + UnitAmount: unitAmount, + }) + } + return result +} + +func validateCustomerDetails(input checkoutCustomerDetails) (checkoutCustomerDetails, error) { + normalized := checkoutCustomerDetails{ + Email: strings.TrimSpace(input.Email), + Phone: strings.TrimSpace(input.Phone), + FirstName: strings.TrimSpace(input.FirstName), + LastName: strings.TrimSpace(input.LastName), + AddressLine1: strings.TrimSpace(input.AddressLine1), + AddressLine2: strings.TrimSpace(input.AddressLine2), + City: strings.TrimSpace(input.City), + Region: strings.TrimSpace(input.Region), + PostalCode: strings.TrimSpace(input.PostalCode), + Country: strings.TrimSpace(input.Country), + Notes: strings.TrimSpace(input.Notes), + } + + if !emailPattern.MatchString(normalized.Email) { + return normalized, fmt.Errorf("Enter a valid email address before continuing to payment.") + } + if len(normalized.Phone) < 7 { + return normalized, fmt.Errorf("Enter a valid phone number before continuing to payment.") + } + if len(normalized.FirstName) < 2 || len(normalized.LastName) < 2 { + return normalized, fmt.Errorf("Enter the full shipping name before continuing to payment.") + } + if len(normalized.AddressLine1) < 5 || len(normalized.City) < 2 || len(normalized.Region) < 2 || len(normalized.PostalCode) < 3 || len(normalized.Country) < 2 { + return normalized, fmt.Errorf("Complete the full shipping address before continuing to payment.") + } + + return normalized, nil +} + +func orderIDFromOrder(order *orders.Order) string { + if order == nil { + return "" + } + + return order.ID +} + +func orderStatusFromOrder(order *orders.Order) string { + if order == nil { + return "" + } + + return order.Status +} + +func pluralize(value int64) string { + if value == 1 { + return "" + } + + return "s" +} + +func paymentIntentCreatedMessage(cfg *config.Config) string { + if cfg.IsDevelopment() { + return "Payment session created. Complete the payment form, then wait for Stripe webhook verification to confirm the order." + } + + return "Payment session created. Complete the payment form, then wait for Stripe webhook verification to confirm the order." +} + +func defaultStatusMessage(status stripe.PaymentIntentStatus, verified bool) string { + if verified { + return "Stripe confirmed your payment and updated the order record." + } + + switch status { + case stripe.PaymentIntentStatusSucceeded: + return "Stripe reports this payment as succeeded, but the backend is still waiting for the webhook confirmation." + case stripe.PaymentIntentStatusProcessing: + return "Stripe is still processing this payment intent." + case stripe.PaymentIntentStatusCanceled: + return "Stripe marked this payment intent as canceled." + case stripe.PaymentIntentStatusRequiresPaymentMethod: + return "Stripe needs a valid payment method before this payment can complete." + case stripe.PaymentIntentStatusRequiresAction: + return "Stripe requires additional customer action before this payment can complete." + default: + return "Waiting for the next Stripe payment update." + } +} + +func serializeOrder(order *orders.Order) map[string]any { + if order == nil { + return nil + } + + items := make([]map[string]any, 0, len(order.Items)) + for _, item := range order.Items { + items = append(items, map[string]any{ + "itemId": item.ItemID, + "style": item.Style, + "colorwayId": item.ColorwayID, + "finishId": item.FinishID, + "quantity": item.Quantity, + "unitAmount": item.UnitAmount, + }) + } + + return map[string]any{ + "id": order.ID, + "status": order.Status, + "currency": order.Currency, + "amount": order.Amount, + "webhookStatus": order.WebhookStatus, + "items": items, + } +} diff --git a/Backend/internal/httpx/client_dashboard_routes.go b/Backend/internal/httpx/client_dashboard_routes.go new file mode 100644 index 0000000..6bf0da7 --- /dev/null +++ b/Backend/internal/httpx/client_dashboard_routes.go @@ -0,0 +1,755 @@ +package httpx + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + "royal-pop-backend/internal/config" + "royal-pop-backend/internal/database" + "royal-pop-backend/internal/inventory" + "royal-pop-backend/internal/mailer" + "royal-pop-backend/internal/orders" +) + +const clientSessionCookieName = "royal_pop_client_session" + +type clientLoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type clientStockUpdateRequest struct { + Style string `json:"style"` + ColorwayID string `json:"colorwayId"` + FinishID string `json:"finishId"` + QuantityOnHand int64 `json:"quantityOnHand"` + Notes string `json:"notes"` +} + +type clientOrderUpdateRequest struct { + FulfillmentStatus string `json:"fulfillmentStatus"` + ShippingCarrier string `json:"shippingCarrier"` + TrackingNumber string `json:"trackingNumber"` + FulfillmentNotes string `json:"fulfillmentNotes"` + ClearShippedAt bool `json:"clearShippedAt"` + MarkShippedAtNow bool `json:"markShippedAtNow"` +} + +func handleClientReseed(db *database.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if db == nil { + WriteError(w, http.StatusServiceUnavailable, "reseed_unavailable", "Database reseed is not configured yet.") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + summary, err := db.BackupAndResetAppData(ctx) + if err != nil { + WriteError(w, http.StatusInternalServerError, "reseed_failed", "The database backup or reset could not be completed.") + return + } + + WriteJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "backupPath": summary.BackupPath, + "ordersCount": summary.OrdersCount, + "orderItemsCount": summary.OrderItemsCount, + "inventoryCount": summary.InventoryCount, + "message": "Backup created and the local database has been reset to an empty state.", + }, + }) + } +} + +func handleListPublicInventory(stockStore *inventory.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if stockStore == nil { + WriteJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "inventory": []map[string]any{}, + }, + }) + return + } + + levels, err := stockStore.List(r.Context()) + if err != nil { + WriteError(w, http.StatusInternalServerError, "inventory_list_failed", "The storefront could not load inventory availability.") + return + } + + serialized := make([]map[string]any, 0, len(levels)) + for _, level := range levels { + serialized = append(serialized, map[string]any{ + "style": level.Style, + "colorwayId": level.ColorwayID, + "finishId": level.FinishID, + "quantityOnHand": level.QuantityOnHand, + "updatedAt": level.UpdatedAt.Format(time.RFC3339), + }) + } + + WriteJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "inventory": serialized, + }, + }) + } +} + +func handleGetClientSession(cfg *config.Config) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !cfg.ClientDashboardEnabled() { + WriteJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "authenticated": false, + "configured": false, + }, + }) + return + } + + username, expiresAt, ok := validateClientSessionCookie(cfg, r) + WriteJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "authenticated": ok, + "configured": true, + "username": username, + "expiresAt": expiresAt, + }, + }) + } +} + +func handleClientLogin(cfg *config.Config) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !cfg.ClientDashboardEnabled() { + WriteError(w, http.StatusServiceUnavailable, "client_dashboard_not_configured", "Client dashboard credentials are not configured yet.") + return + } + + var request clientLoginRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + WriteError(w, http.StatusBadRequest, "invalid_json", "Provide a valid username and password.") + return + } + + if subtle.ConstantTimeCompare([]byte(strings.TrimSpace(request.Username)), []byte(cfg.ClientDashboardUser)) != 1 || + subtle.ConstantTimeCompare([]byte(request.Password), []byte(cfg.ClientDashboardPass)) != 1 { + WriteError(w, http.StatusUnauthorized, "invalid_client_credentials", "The client dashboard username or password is incorrect.") + return + } + + expiresAt := time.Now().Add(cfg.ClientDashboardSessionTTL) + setClientSessionCookie(w, cfg, cfg.ClientDashboardUser, expiresAt) + + WriteJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "authenticated": true, + "configured": true, + "username": cfg.ClientDashboardUser, + "expiresAt": expiresAt.Format(time.RFC3339), + }, + }) + } +} + +func handleClientLogout(cfg *config.Config) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + clearClientSessionCookie(w, cfg) + WriteJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "authenticated": false, + }, + }) + } +} + +func requireClientSession(cfg *config.Config) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !cfg.ClientDashboardEnabled() { + WriteError(w, http.StatusServiceUnavailable, "client_dashboard_not_configured", "Client dashboard credentials are not configured yet.") + return + } + + if _, _, ok := validateClientSessionCookie(cfg, r); !ok { + WriteError(w, http.StatusUnauthorized, "client_dashboard_auth_required", "Please sign in to the client dashboard first.") + return + } + + next.ServeHTTP(w, r) + }) + } +} + +func handleListClientOrders(orderStore *orders.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if orderStore == nil { + WriteError(w, http.StatusServiceUnavailable, "orders_unavailable", "Order storage is not configured yet.") + return + } + + limit := 25 + if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { + parsed, err := strconv.Atoi(raw) + if err == nil { + limit = parsed + } + } + + search := strings.TrimSpace(r.URL.Query().Get("search")) + status := strings.TrimSpace(r.URL.Query().Get("status")) + + ordersList, err := orderStore.ListForDashboard(r.Context(), orders.ListFilter{ + Limit: limit, + Search: search, + FulfillmentStatus: status, + }) + if err != nil { + WriteError(w, http.StatusInternalServerError, "orders_list_failed", "The client dashboard could not load recent orders.") + return + } + + serialized := make([]map[string]any, 0, len(ordersList)) + for _, order := range ordersList { + serialized = append(serialized, serializeClientOrderSummary(order)) + } + + WriteJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "orders": serialized, + }, + }) + } +} + +func handleGetClientOrder(orderStore *orders.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if orderStore == nil { + WriteError(w, http.StatusServiceUnavailable, "orders_unavailable", "Order storage is not configured yet.") + return + } + + orderID := strings.TrimSpace(chi.URLParam(r, "orderID")) + if orderID == "" { + WriteError(w, http.StatusBadRequest, "invalid_order_id", "Provide a valid order ID.") + return + } + + order, err := orderStore.GetByID(r.Context(), orderID) + if err != nil { + if err == orders.ErrOrderNotFound { + WriteError(w, http.StatusNotFound, "order_not_found", "That order could not be found.") + return + } + WriteError(w, http.StatusInternalServerError, "order_load_failed", "The client dashboard could not load this order.") + return + } + + WriteJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "order": serializeClientOrderDetail(order), + }, + }) + } +} + +func handleUpdateClientOrder(orderStore *orders.Store, fulfillmentMailer *mailer.ResendMailer) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if orderStore == nil { + WriteError(w, http.StatusServiceUnavailable, "orders_unavailable", "Order storage is not configured yet.") + return + } + + orderID := strings.TrimSpace(chi.URLParam(r, "orderID")) + if orderID == "" { + WriteError(w, http.StatusBadRequest, "invalid_order_id", "Provide a valid order ID.") + return + } + + existingOrder, err := orderStore.GetByID(r.Context(), orderID) + if err != nil { + if err == orders.ErrOrderNotFound { + WriteError(w, http.StatusNotFound, "order_not_found", "That order could not be found.") + return + } + WriteError(w, http.StatusInternalServerError, "order_load_failed", "The client dashboard could not load this order.") + return + } + + var request clientOrderUpdateRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + WriteError(w, http.StatusBadRequest, "invalid_json", "Provide a valid order update payload.") + return + } + + updatedOrder, err := orderStore.UpdateFulfillment(r.Context(), orders.UpdateFulfillmentInput{ + OrderID: orderID, + FulfillmentStatus: request.FulfillmentStatus, + ShippingCarrier: request.ShippingCarrier, + TrackingNumber: request.TrackingNumber, + FulfillmentNotes: request.FulfillmentNotes, + ClearShippedAt: request.ClearShippedAt, + MarkShippedAtNow: request.MarkShippedAtNow, + }) + if err != nil { + switch err { + case orders.ErrOrderNotFound: + WriteError(w, http.StatusNotFound, "order_not_found", "That order could not be found.") + return + default: + if strings.Contains(err.Error(), "invalid fulfillment status") { + WriteError(w, http.StatusBadRequest, "invalid_fulfillment_status", "Choose a valid fulfillment status.") + return + } + WriteError(w, http.StatusInternalServerError, "order_update_failed", "The order update could not be saved.") + return + } + } + + shipmentEmail := map[string]any{ + "status": "skipped", + "message": "Fulfilment email was not sent.", + } + + if fulfillmentMailer == nil || !fulfillmentMailer.Enabled() { + shipmentEmail["reason"] = "not_configured" + shipmentEmail["message"] = "Fulfilment email is not configured." + } else if shouldSendShipmentEmail(existingOrder, updatedOrder) { + if err := fulfillmentMailer.SendOrderShipped(r.Context(), updatedOrder); err != nil { + log.Printf("client order shipment email failed for %s: %v", updatedOrder.ID, err) + shipmentEmail["status"] = "failed" + shipmentEmail["message"] = "The order was updated, but the shipment email could not be sent." + shipmentEmail["reason"] = "send_failed" + } else { + shipmentEmail["status"] = "sent" + shipmentEmail["message"] = "Shipment email sent to the customer." + } + } else if shouldSendCancellationEmail(existingOrder, updatedOrder) { + if err := fulfillmentMailer.SendOrderCancelled(r.Context(), updatedOrder); err != nil { + log.Printf("client order cancellation email failed for %s: %v", updatedOrder.ID, err) + shipmentEmail["status"] = "failed" + shipmentEmail["message"] = "The order was updated, but the cancellation email could not be sent." + shipmentEmail["reason"] = "send_failed" + } else { + shipmentEmail["status"] = "sent" + shipmentEmail["message"] = "Cancellation email sent to the customer." + } + } else if shouldSendDeliveredEmail(existingOrder, updatedOrder) { + if err := fulfillmentMailer.SendOrderDelivered(r.Context(), updatedOrder); err != nil { + log.Printf("client order delivered email failed for %s: %v", updatedOrder.ID, err) + shipmentEmail["status"] = "failed" + shipmentEmail["message"] = "The order was updated, but the delivered email could not be sent." + shipmentEmail["reason"] = "send_failed" + } else { + shipmentEmail["status"] = "sent" + shipmentEmail["message"] = "Delivered email sent to the customer." + } + } else { + shipmentEmail["reason"] = shipmentEmailSkipReason(existingOrder, updatedOrder) + } + + WriteJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "order": serializeClientOrderDetail(updatedOrder), + "shipmentEmail": shipmentEmail, + }, + }) + } +} + +func shouldSendShipmentEmail(previousOrder, updatedOrder *orders.Order) bool { + if previousOrder == nil || updatedOrder == nil { + return false + } + + if !strings.EqualFold(updatedOrder.FulfillmentStatus, orders.FulfillmentShipped) { + return false + } + + if strings.EqualFold(previousOrder.FulfillmentStatus, orders.FulfillmentShipped) { + return false + } + + if strings.TrimSpace(updatedOrder.Email) == "" { + return false + } + + if strings.TrimSpace(updatedOrder.ShippingCarrier) == "" || strings.TrimSpace(updatedOrder.TrackingNumber) == "" { + return false + } + + return true +} + +func shouldSendCancellationEmail(previousOrder, updatedOrder *orders.Order) bool { + if previousOrder == nil || updatedOrder == nil { + return false + } + + if !strings.EqualFold(updatedOrder.FulfillmentStatus, orders.FulfillmentCancelled) { + return false + } + + if strings.EqualFold(previousOrder.FulfillmentStatus, orders.FulfillmentCancelled) { + return false + } + + if strings.TrimSpace(updatedOrder.Email) == "" { + return false + } + + return true +} + +func shouldSendDeliveredEmail(previousOrder, updatedOrder *orders.Order) bool { + if previousOrder == nil || updatedOrder == nil { + return false + } + + if !strings.EqualFold(updatedOrder.FulfillmentStatus, orders.FulfillmentDelivered) { + return false + } + + if strings.EqualFold(previousOrder.FulfillmentStatus, orders.FulfillmentDelivered) { + return false + } + + if strings.TrimSpace(updatedOrder.Email) == "" { + return false + } + + return true +} + +func shipmentEmailSkipReason(previousOrder, updatedOrder *orders.Order) string { + if updatedOrder == nil { + return "order_unavailable" + } + + if strings.EqualFold(updatedOrder.FulfillmentStatus, orders.FulfillmentShipped) { + if previousOrder != nil && strings.EqualFold(previousOrder.FulfillmentStatus, orders.FulfillmentShipped) { + return "already_shipped" + } + + if strings.TrimSpace(updatedOrder.Email) == "" { + return "missing_email" + } + + if strings.TrimSpace(updatedOrder.ShippingCarrier) == "" || strings.TrimSpace(updatedOrder.TrackingNumber) == "" { + return "missing_tracking" + } + + return "not_applicable" + } + + if strings.EqualFold(updatedOrder.FulfillmentStatus, orders.FulfillmentCancelled) { + if previousOrder != nil && strings.EqualFold(previousOrder.FulfillmentStatus, orders.FulfillmentCancelled) { + return "already_cancelled" + } + + if strings.TrimSpace(updatedOrder.Email) == "" { + return "missing_email" + } + + return "not_applicable" + } + + if strings.EqualFold(updatedOrder.FulfillmentStatus, orders.FulfillmentDelivered) { + if previousOrder != nil && strings.EqualFold(previousOrder.FulfillmentStatus, orders.FulfillmentDelivered) { + return "already_delivered" + } + + if strings.TrimSpace(updatedOrder.Email) == "" { + return "missing_email" + } + + return "not_applicable" + } + + return "status_not_supported" +} + +func handleListClientStock(stockStore *inventory.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if stockStore == nil { + WriteError(w, http.StatusServiceUnavailable, "stock_unavailable", "Stock storage is not configured yet.") + return + } + + levels, err := stockStore.List(r.Context()) + if err != nil { + WriteError(w, http.StatusInternalServerError, "stock_list_failed", "The client dashboard could not load stock levels.") + return + } + + serialized := make([]map[string]any, 0, len(levels)) + for _, level := range levels { + serialized = append(serialized, map[string]any{ + "style": level.Style, + "colorwayId": level.ColorwayID, + "finishId": level.FinishID, + "quantityOnHand": level.QuantityOnHand, + "notes": level.Notes, + "createdAt": level.CreatedAt.Format(time.RFC3339), + "updatedAt": level.UpdatedAt.Format(time.RFC3339), + }) + } + + WriteJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "stock": serialized, + }, + }) + } +} + +func handleUpsertClientStock(stockStore *inventory.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if stockStore == nil { + WriteError(w, http.StatusServiceUnavailable, "stock_unavailable", "Stock storage is not configured yet.") + return + } + + var request clientStockUpdateRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + WriteError(w, http.StatusBadRequest, "invalid_json", "Provide a valid stock update payload.") + return + } + + request.Style = strings.TrimSpace(strings.ToUpper(request.Style)) + request.ColorwayID = strings.TrimSpace(request.ColorwayID) + request.FinishID = strings.TrimSpace(request.FinishID) + request.Notes = strings.TrimSpace(request.Notes) + + if request.Style != "A" && request.Style != "B" { + WriteError(w, http.StatusBadRequest, "invalid_style", "Stock entries must use style A or B.") + return + } + if request.ColorwayID == "" || request.FinishID == "" { + WriteError(w, http.StatusBadRequest, "invalid_stock_key", "Stock entries need a colorway and finish.") + return + } + if request.QuantityOnHand < 0 { + WriteError(w, http.StatusBadRequest, "invalid_quantity", "Stock quantity cannot be negative.") + return + } + + level, err := stockStore.Upsert(r.Context(), inventory.UpsertInput{ + Style: request.Style, + ColorwayID: request.ColorwayID, + FinishID: request.FinishID, + QuantityOnHand: request.QuantityOnHand, + Notes: request.Notes, + }) + if err != nil { + WriteError(w, http.StatusInternalServerError, "stock_save_failed", "The stock level could not be saved.") + return + } + + WriteJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "stock": map[string]any{ + "style": level.Style, + "colorwayId": level.ColorwayID, + "finishId": level.FinishID, + "quantityOnHand": level.QuantityOnHand, + "notes": level.Notes, + "createdAt": level.CreatedAt.Format(time.RFC3339), + "updatedAt": level.UpdatedAt.Format(time.RFC3339), + }, + }, + }) + } +} + +func validateClientSessionCookie(cfg *config.Config, r *http.Request) (string, string, bool) { + if cfg == nil || r == nil || !cfg.ClientDashboardEnabled() { + return "", "", false + } + + cookie, err := r.Cookie(clientSessionCookieName) + if err != nil || strings.TrimSpace(cookie.Value) == "" { + return "", "", false + } + + decoded, err := base64.RawURLEncoding.DecodeString(cookie.Value) + if err != nil { + return "", "", false + } + + parts := strings.Split(string(decoded), ".") + if len(parts) != 3 { + return "", "", false + } + + username := parts[0] + expiresUnix, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil { + return "", "", false + } + + expiresAt := time.Unix(expiresUnix, 0).UTC() + if time.Now().After(expiresAt) { + return "", "", false + } + + expectedSignature := signClientSession(cfg.ClientDashboardSigningSecret(), username, expiresUnix) + if subtle.ConstantTimeCompare([]byte(parts[2]), []byte(expectedSignature)) != 1 { + return "", "", false + } + + if subtle.ConstantTimeCompare([]byte(username), []byte(cfg.ClientDashboardUser)) != 1 { + return "", "", false + } + + return username, expiresAt.Format(time.RFC3339), true +} + +func setClientSessionCookie(w http.ResponseWriter, cfg *config.Config, username string, expiresAt time.Time) { + expiresUnix := expiresAt.UTC().Unix() + token := fmt.Sprintf("%s.%d.%s", username, expiresUnix, signClientSession(cfg.ClientDashboardSigningSecret(), username, expiresUnix)) + + http.SetCookie(w, &http.Cookie{ + Name: clientSessionCookieName, + Value: base64.RawURLEncoding.EncodeToString([]byte(token)), + Path: "/", + Expires: expiresAt, + MaxAge: int(time.Until(expiresAt).Seconds()), + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: !cfg.IsDevelopment(), + }) +} + +func clearClientSessionCookie(w http.ResponseWriter, cfg *config.Config) { + http.SetCookie(w, &http.Cookie{ + Name: clientSessionCookieName, + Value: "", + Path: "/", + Expires: time.Unix(0, 0), + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: !cfg.IsDevelopment(), + }) +} + +func signClientSession(secret, username string, expiresUnix int64) string { + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(fmt.Sprintf("%s|%d", username, expiresUnix))) + return hex.EncodeToString(mac.Sum(nil)) +} + +func serializeClientOrderSummary(order orders.Order) map[string]any { + itemCount := int64(0) + items := make([]map[string]any, 0, len(order.Items)) + for _, item := range order.Items { + itemCount += item.Quantity + items = append(items, map[string]any{ + "style": item.Style, + "colorwayId": item.ColorwayID, + "finishId": item.FinishID, + "quantity": item.Quantity, + "unitAmount": item.UnitAmount, + }) + } + + return map[string]any{ + "id": order.ID, + "status": order.Status, + "fulfillmentStatus": order.FulfillmentStatus, + "email": order.Email, + "phone": order.Phone, + "firstName": order.FirstName, + "lastName": order.LastName, + "city": order.City, + "region": order.Region, + "postalCode": order.PostalCode, + "country": order.Country, + "notes": order.Notes, + "currency": order.Currency, + "amount": order.Amount, + "shippingCarrier": order.ShippingCarrier, + "trackingNumber": order.TrackingNumber, + "webhookStatus": order.WebhookStatus, + "webhookMessage": order.WebhookMessage, + "createdAt": order.CreatedAt.Format(time.RFC3339), + "updatedAt": order.UpdatedAt.Format(time.RFC3339), + "shippedAt": formatOptionalTime(order.ShippedAt), + "itemCount": itemCount, + "items": items, + } +} + +func serializeClientOrderDetail(order *orders.Order) map[string]any { + if order == nil { + return map[string]any{} + } + + items := make([]map[string]any, 0, len(order.Items)) + for _, item := range order.Items { + items = append(items, map[string]any{ + "itemId": item.ItemID, + "style": item.Style, + "colorwayId": item.ColorwayID, + "finishId": item.FinishID, + "quantity": item.Quantity, + "unitAmount": item.UnitAmount, + }) + } + + return map[string]any{ + "id": order.ID, + "status": order.Status, + "fulfillmentStatus": order.FulfillmentStatus, + "email": order.Email, + "phone": order.Phone, + "firstName": order.FirstName, + "lastName": order.LastName, + "addressLine1": order.AddressLine1, + "addressLine2": order.AddressLine2, + "city": order.City, + "region": order.Region, + "postalCode": order.PostalCode, + "country": order.Country, + "notes": order.Notes, + "currency": order.Currency, + "amount": order.Amount, + "shippingCarrier": order.ShippingCarrier, + "trackingNumber": order.TrackingNumber, + "fulfillmentNotes": order.FulfillmentNotes, + "webhookStatus": order.WebhookStatus, + "webhookEventId": order.WebhookEventID, + "webhookEventType": order.WebhookEventType, + "webhookMessage": order.WebhookMessage, + "stripePaymentIntentId": order.StripePaymentIntentID, + "createdAt": order.CreatedAt.Format(time.RFC3339), + "updatedAt": order.UpdatedAt.Format(time.RFC3339), + "shippedAt": formatOptionalTime(order.ShippedAt), + "items": items, + } +} + +func formatOptionalTime(value *time.Time) any { + if value == nil { + return nil + } + return value.UTC().Format(time.RFC3339) +} diff --git a/Backend/internal/httpx/payment_state.go b/Backend/internal/httpx/payment_state.go new file mode 100644 index 0000000..7c95d71 --- /dev/null +++ b/Backend/internal/httpx/payment_state.go @@ -0,0 +1,105 @@ +package httpx + +import ( + "strings" + "sync" + "time" +) + +type paymentIntentVerification struct { + PaymentIntentID string `json:"paymentIntentId"` + WebhookState string `json:"webhookState"` + WebhookEventID string `json:"webhookEventId,omitempty"` + WebhookEventType string `json:"webhookEventType,omitempty"` + Message string `json:"message"` + Verified bool `json:"verified"` + UpdatedAt time.Time `json:"updatedAt"` + CreatedAt time.Time `json:"createdAt"` +} + +type paymentIntentStateStore struct { + mu sync.RWMutex + records map[string]paymentIntentVerification +} + +func newPaymentIntentStateStore() *paymentIntentStateStore { + return &paymentIntentStateStore{records: make(map[string]paymentIntentVerification)} +} + +func (s *paymentIntentStateStore) upsertCreated(paymentIntentID string) { + if strings.TrimSpace(paymentIntentID) == "" { + return + } + + now := time.Now().UTC() + + s.mu.Lock() + defer s.mu.Unlock() + + record, exists := s.records[paymentIntentID] + if !exists { + record = paymentIntentVerification{ + PaymentIntentID: paymentIntentID, + CreatedAt: now, + } + } + + record.WebhookState = "awaiting_webhook" + record.Message = "Waiting for Stripe webhook verification." + record.Verified = false + record.UpdatedAt = now + + s.records[paymentIntentID] = record +} + +func (s *paymentIntentStateStore) applyWebhook(result *stripeWebhookResult) { + if result == nil || strings.TrimSpace(result.PaymentIntentID) == "" { + return + } + + now := time.Now().UTC() + + s.mu.Lock() + defer s.mu.Unlock() + + record, exists := s.records[result.PaymentIntentID] + if !exists { + record = paymentIntentVerification{ + PaymentIntentID: result.PaymentIntentID, + CreatedAt: now, + } + } + + record.WebhookEventID = result.EventID + record.WebhookEventType = result.EventType + record.Message = result.Message + record.Verified = result.Verified + record.UpdatedAt = now + + switch result.OrderState { + case "paid_verified_local_only", "paid_unrecorded": + record.WebhookState = "succeeded" + case "payment_failed_verified_local_only", "payment_failed_unrecorded": + record.WebhookState = "failed" + case "processing_verified_local_only", "processing_unrecorded": + record.WebhookState = "processing" + case "canceled_verified_local_only", "canceled_unrecorded": + record.WebhookState = "canceled" + case "ignored": + record.WebhookState = "ignored" + default: + record.WebhookState = "awaiting_webhook" + } + + s.records[result.PaymentIntentID] = record +} + +func (s *paymentIntentStateStore) get(paymentIntentID string) (paymentIntentVerification, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + record, exists := s.records[paymentIntentID] + return record, exists +} + +var paymentIntentStates = newPaymentIntentStateStore() diff --git a/Backend/internal/httpx/response.go b/Backend/internal/httpx/response.go new file mode 100644 index 0000000..edcb341 --- /dev/null +++ b/Backend/internal/httpx/response.go @@ -0,0 +1,30 @@ +package httpx + +import ( + "encoding/json" + "net/http" +) + +type errorEnvelope struct { + Error errorBody `json:"error"` +} + +type errorBody struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func WriteJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +func WriteError(w http.ResponseWriter, status int, code, message string) { + WriteJSON(w, status, errorEnvelope{ + Error: errorBody{ + Code: code, + Message: message, + }, + }) +} diff --git a/Backend/internal/httpx/router.go b/Backend/internal/httpx/router.go new file mode 100644 index 0000000..dc281b9 --- /dev/null +++ b/Backend/internal/httpx/router.go @@ -0,0 +1,132 @@ +package httpx + +import ( + "context" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + chimiddleware "github.com/go-chi/chi/v5/middleware" + + "royal-pop-backend/internal/config" + "royal-pop-backend/internal/database" + "royal-pop-backend/internal/inventory" + "royal-pop-backend/internal/mailer" + "royal-pop-backend/internal/orders" +) + +type RouterConfig struct { + Config *config.Config + DB *database.DB + Orders *orders.Store + Stock *inventory.Store + Mailer *mailer.ResendMailer +} + +func NewRouter(cfg RouterConfig) http.Handler { + router := chi.NewRouter() + + router.Use(chimiddleware.RealIP) + router.Use(chimiddleware.RequestID) + router.Use(chimiddleware.Recoverer) + router.Use(chimiddleware.Timeout(30 * time.Second)) + router.Use(corsMiddleware(cfg.Config.AllowedOrigins)) + + router.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) { + databaseStatus := "disabled" + if cfg.DB != nil { + healthCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := cfg.DB.Health(healthCtx); err != nil { + WriteJSON(w, http.StatusServiceUnavailable, map[string]any{ + "service": cfg.Config.AppName, + "status": "degraded", + "database": "unreachable", + }) + return + } + databaseStatus = "ok" + } + + WriteJSON(w, http.StatusOK, map[string]any{ + "service": cfg.Config.AppName, + "status": "ok", + "database": databaseStatus, + }) + }) + + router.Post("/webhooks/stripe", handleStripeWebhook(cfg.Config, cfg.Orders, cfg.Mailer)) + + router.Route("/v1", func(api chi.Router) { + api.Get("/inventory", handleListPublicInventory(cfg.Stock)) + api.Get("/storefront/pricing", handleGetStorefrontPricing(cfg.Config)) + api.Get("/", func(w http.ResponseWriter, _ *http.Request) { + WriteJSON(w, http.StatusOK, map[string]any{ + "service": cfg.Config.AppName, + "status": "scaffolded", + "version": "v1", + }) + }) + + api.Post("/checkout/payment-intent", handleCreatePaymentIntent(cfg.Config, cfg.Orders)) + api.Get("/checkout/payment-intent/{paymentIntentID}", handleGetPaymentIntentStatus(cfg.Config, cfg.Orders)) + api.Get("/client/session", handleGetClientSession(cfg.Config)) + api.Post("/client/login", handleClientLogin(cfg.Config)) + api.Post("/client/logout", handleClientLogout(cfg.Config)) + + api.Group(func(client chi.Router) { + client.Use(requireClientSession(cfg.Config)) + client.Get("/client/orders", handleListClientOrders(cfg.Orders)) + client.Get("/client/orders/{orderID}", handleGetClientOrder(cfg.Orders)) + client.Patch("/client/orders/{orderID}", handleUpdateClientOrder(cfg.Orders, cfg.Mailer)) + client.Get("/client/stock", handleListClientStock(cfg.Stock)) + client.Put("/client/stock", handleUpsertClientStock(cfg.Stock)) + client.Post("/client/reseed", handleClientReseed(cfg.DB)) + }) + }) + + router.NotFound(func(w http.ResponseWriter, _ *http.Request) { + WriteError(w, http.StatusNotFound, "not_found", "The requested endpoint does not exist.") + }) + + return router +} + +func corsMiddleware(allowedOrigins []string) func(http.Handler) http.Handler { + normalized := make([]string, 0, len(allowedOrigins)) + for _, origin := range allowedOrigins { + trimmed := strings.TrimSpace(origin) + if trimmed == "" { + continue + } + normalized = append(normalized, trimmed) + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + + if origin != "" { + for _, allowedOrigin := range normalized { + if allowedOrigin == "*" || strings.EqualFold(allowedOrigin, origin) { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin") + w.Header().Set("Access-Control-Allow-Credentials", "true") + break + } + } + } + + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, OPTIONS") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/Backend/internal/httpx/webhook_routes.go b/Backend/internal/httpx/webhook_routes.go new file mode 100644 index 0000000..3775f2a --- /dev/null +++ b/Backend/internal/httpx/webhook_routes.go @@ -0,0 +1,236 @@ +package httpx + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" + + "github.com/stripe/stripe-go/v83" + stripewebhook "github.com/stripe/stripe-go/v83/webhook" + + "royal-pop-backend/internal/config" + "royal-pop-backend/internal/mailer" + "royal-pop-backend/internal/orders" +) + +const maxStripeWebhookBodyBytes int64 = 64 * 1024 + +type stripeWebhookResult struct { + EventID string `json:"eventId"` + EventType string `json:"eventType"` + PaymentIntentID string `json:"paymentIntentId,omitempty"` + OrderState string `json:"orderState"` + Persistence string `json:"persistence"` + Message string `json:"message"` + Verified bool `json:"verified"` +} + +func handleStripeWebhook(cfg *config.Config, orderStore *orders.Store, fulfillmentMailer *mailer.ResendMailer) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if cfg.StripeWebhookSecret == "" { + WriteError(w, http.StatusInternalServerError, "stripe_webhook_not_configured", "Missing STRIPE_WEBHOOK_SECRET for Stripe webhook verification.") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxStripeWebhookBodyBytes) + payload, err := io.ReadAll(r.Body) + if err != nil { + WriteError(w, http.StatusBadRequest, "invalid_webhook_payload", "The Stripe webhook payload could not be read.") + return + } + + signature := r.Header.Get("Stripe-Signature") + if strings.TrimSpace(signature) == "" { + WriteError(w, http.StatusBadRequest, "missing_stripe_signature", "Missing Stripe-Signature header.") + return + } + + constructOptions := stripewebhook.ConstructEventOptions{} + constructOptions.IgnoreAPIVersionMismatch = true + + event, err := stripewebhook.ConstructEventWithOptions(payload, signature, cfg.StripeWebhookSecret, constructOptions) + if err != nil { + status := http.StatusBadRequest + if errors.Is(err, stripewebhook.ErrTooOld) { + status = http.StatusUnauthorized + } + log.Printf("stripe webhook rejected: status=%d error=%v", status, err) + WriteError(w, status, "invalid_stripe_signature", err.Error()) + return + } + + result, err := processStripeWebhookEvent(event) + if err != nil { + WriteError(w, http.StatusBadRequest, "unsupported_webhook_payload", err.Error()) + return + } + + paymentIntentStates.applyWebhook(result) + + if orderStore != nil && result.PaymentIntentID != "" { + updateCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + previousOrder, err := orderStore.GetByPaymentIntentID(updateCtx, result.PaymentIntentID) + if err != nil && !errors.Is(err, orders.ErrOrderNotFound) { + WriteError(w, http.StatusInternalServerError, "order_lookup_failed", "Stripe webhook verification succeeded, but the current order record could not be loaded.") + return + } + + if err := orderStore.UpdateFromWebhook(updateCtx, result.PaymentIntentID, orders.WebhookUpdate{ + Status: orderStatusForWebhookResult(result.OrderState), + WebhookStatus: webhookStatusForWebhookResult(result.OrderState), + WebhookEventID: result.EventID, + WebhookEventType: result.EventType, + WebhookMessage: result.Message, + }); err != nil && !errors.Is(err, orders.ErrOrderNotFound) { + WriteError(w, http.StatusInternalServerError, "order_webhook_update_failed", "Stripe webhook verification succeeded, but the order record could not be updated.") + return + } + + if fulfillmentMailer != nil && fulfillmentMailer.Enabled() { + updatedOrder, err := orderStore.GetByPaymentIntentID(updateCtx, result.PaymentIntentID) + if err != nil && !errors.Is(err, orders.ErrOrderNotFound) { + WriteError(w, http.StatusInternalServerError, "order_reload_failed", "Stripe webhook verification succeeded, but the updated order record could not be loaded.") + return + } + + if shouldSendPaidEmail(previousOrder, updatedOrder) { + if err := fulfillmentMailer.SendOrderPaid(updateCtx, updatedOrder); err != nil { + log.Printf("paid email failed for order %s: %v", updatedOrder.ID, err) + } + + if err := fulfillmentMailer.SendOrderCreated(updateCtx, updatedOrder); err != nil { + log.Printf("order created reminder email failed for order %s: %v", updatedOrder.ID, err) + } + } + } + } + + log.Printf("stripe webhook received: event_id=%s type=%s payment_intent=%s order_state=%s", result.EventID, result.EventType, result.PaymentIntentID, result.OrderState) + + WriteJSON(w, http.StatusOK, map[string]any{ + "received": true, + "data": result, + }) + } +} + +func shouldSendPaidEmail(previousOrder, updatedOrder *orders.Order) bool { + if previousOrder == nil || updatedOrder == nil { + return false + } + + if updatedOrder.Status != orders.StatusPaid { + return false + } + + if previousOrder.Status == orders.StatusPaid { + return false + } + + return strings.TrimSpace(updatedOrder.Email) != "" +} + +func processStripeWebhookEvent(event stripe.Event) (*stripeWebhookResult, error) { + intent, err := decodePaymentIntentFromEvent(event) + if err != nil { + return nil, err + } + + result := &stripeWebhookResult{ + EventID: event.ID, + EventType: string(event.Type), + PaymentIntentID: intent.ID, + Persistence: "not_persisted_yet", + } + + switch event.Type { + case stripe.EventTypePaymentIntentSucceeded: + result.OrderState = "paid_verified_local_only" + result.Verified = true + result.Message = "Stripe confirmed the payment and updated the order record." + case stripe.EventTypePaymentIntentPaymentFailed: + result.OrderState = "payment_failed_verified_local_only" + result.Verified = true + result.Message = buildPaymentIntentFailureMessage(intent) + case stripe.EventTypePaymentIntentProcessing: + result.OrderState = "processing_verified_local_only" + result.Verified = true + result.Message = "Stripe marked the payment intent as processing. The order record has been updated and is waiting for the next payment event." + case stripe.EventTypePaymentIntentCanceled: + result.OrderState = "canceled_verified_local_only" + result.Verified = true + result.Message = "Stripe marked the payment intent as canceled and the order record has been updated." + default: + result.OrderState = "ignored" + result.Verified = false + result.Message = fmt.Sprintf("Received Stripe event %s. Signature verified, but no persistence action is configured for this event yet.", event.Type) + } + + return result, nil +} + +func decodePaymentIntentFromEvent(event stripe.Event) (*stripe.PaymentIntent, error) { + if len(event.Data.Raw) == 0 { + return nil, fmt.Errorf("stripe event %s did not include an object payload", event.Type) + } + + var intent stripe.PaymentIntent + if err := json.Unmarshal(event.Data.Raw, &intent); err != nil { + return nil, fmt.Errorf("stripe event %s could not be parsed as a payment intent: %w", event.Type, err) + } + + return &intent, nil +} + +func buildPaymentIntentFailureMessage(intent *stripe.PaymentIntent) string { + if intent == nil || intent.LastPaymentError == nil { + return "Stripe reported that the payment intent failed and the order record has been updated." + } + + message := strings.TrimSpace(intent.LastPaymentError.Msg) + if message == "" { + return "Stripe reported that the payment intent failed and the order record has been updated." + } + + return fmt.Sprintf("Stripe reported that the payment intent failed: %s", message) +} + +func orderStatusForWebhookResult(orderState string) string { + switch orderState { + case "paid_verified_local_only", "paid_unrecorded": + return orders.StatusPaid + case "payment_failed_verified_local_only", "payment_failed_unrecorded": + return orders.StatusFailed + case "processing_verified_local_only", "processing_unrecorded": + return orders.StatusProcessing + case "canceled_verified_local_only", "canceled_unrecorded": + return orders.StatusCanceled + default: + return orders.StatusPending + } +} + +func webhookStatusForWebhookResult(orderState string) string { + switch orderState { + case "paid_verified_local_only", "paid_unrecorded": + return "succeeded" + case "payment_failed_verified_local_only", "payment_failed_unrecorded": + return "failed" + case "processing_verified_local_only", "processing_unrecorded": + return "processing" + case "canceled_verified_local_only", "canceled_unrecorded": + return "canceled" + case "ignored": + return "ignored" + default: + return "awaiting_webhook" + } +} diff --git a/Backend/internal/inventory/store.go b/Backend/internal/inventory/store.go new file mode 100644 index 0000000..5f2e956 --- /dev/null +++ b/Backend/internal/inventory/store.go @@ -0,0 +1,106 @@ +package inventory + +import ( + "context" + "fmt" + "time" + + "royal-pop-backend/internal/database" +) + +type Level struct { + Style string + ColorwayID string + FinishID string + QuantityOnHand int64 + Notes string + CreatedAt time.Time + UpdatedAt time.Time +} + +type UpsertInput struct { + Style string + ColorwayID string + FinishID string + QuantityOnHand int64 + Notes string +} + +type Store struct { + db *database.DB +} + +func NewStore(db *database.DB) *Store { + return &Store{db: db} +} + +func (s *Store) List(ctx context.Context) ([]Level, error) { + if s == nil || s.db == nil || s.db.Pool == nil { + return nil, fmt.Errorf("postgres store is not configured") + } + + rows, err := s.db.Pool.Query(ctx, ` + SELECT style, colorway_id, finish_id, quantity_on_hand, notes, created_at, updated_at + FROM inventory_levels + ORDER BY style ASC, colorway_id ASC, finish_id ASC + `) + if err != nil { + return nil, err + } + defer rows.Close() + + levels := make([]Level, 0) + for rows.Next() { + var level Level + if err := rows.Scan( + &level.Style, + &level.ColorwayID, + &level.FinishID, + &level.QuantityOnHand, + &level.Notes, + &level.CreatedAt, + &level.UpdatedAt, + ); err != nil { + return nil, err + } + levels = append(levels, level) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return levels, nil +} + +func (s *Store) Upsert(ctx context.Context, input UpsertInput) (*Level, error) { + if s == nil || s.db == nil || s.db.Pool == nil { + return nil, fmt.Errorf("postgres store is not configured") + } + + row := s.db.Pool.QueryRow(ctx, ` + INSERT INTO inventory_levels (style, colorway_id, finish_id, quantity_on_hand, notes) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (style, colorway_id, finish_id) + DO UPDATE SET + quantity_on_hand = EXCLUDED.quantity_on_hand, + notes = EXCLUDED.notes, + updated_at = NOW() + RETURNING style, colorway_id, finish_id, quantity_on_hand, notes, created_at, updated_at + `, input.Style, input.ColorwayID, input.FinishID, input.QuantityOnHand, input.Notes) + + var level Level + if err := row.Scan( + &level.Style, + &level.ColorwayID, + &level.FinishID, + &level.QuantityOnHand, + &level.Notes, + &level.CreatedAt, + &level.UpdatedAt, + ); err != nil { + return nil, err + } + + return &level, nil +} diff --git a/Backend/internal/mailer/resend.go b/Backend/internal/mailer/resend.go new file mode 100644 index 0000000..09df762 --- /dev/null +++ b/Backend/internal/mailer/resend.go @@ -0,0 +1,1065 @@ +package mailer + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + htmltemplate "html/template" + "io" + "net/http" + "net/url" + "strings" + texttemplate "text/template" + "time" + + "royal-pop-backend/internal/config" + "royal-pop-backend/internal/orders" +) + +const resendEndpoint = "https://api.resend.com/emails" + +type ResendMailer struct { + apiKey string + from string + replyTo string + storefrontURL string + httpClient *http.Client +} + +type resendEmailRequest struct { + From string `json:"from"` + To []string `json:"to"` + Subject string `json:"subject"` + HTML string `json:"html,omitempty"` + Text string `json:"text,omitempty"` + ReplyTo string `json:"reply_to,omitempty"` +} + +type shippedEmailTemplateData struct { + FirstName string + OrderID string + ShippedDate string + ShippingCarrier string + TrackingNumber string + TrackingURL string + OrderImageURL string + OrderImageAlt string + Items []string + StorefrontURL string +} + +type cancelledEmailTemplateData struct { + FirstName string + OrderID string + CancelledDate string + RefundAmount string + OrderImageURL string + OrderImageAlt string + Items []string + StorefrontURL string +} + +type paidEmailTemplateData struct { + FirstName string + OrderID string + PaidDate string + PaidAmount string + OrderImageURL string + OrderImageAlt string + Items []string + StorefrontURL string +} + +type createdEmailTemplateData struct { + OrderID string + CreatedDate string + OrderAmount string + CustomerName string + CustomerEmail string + OrderImageURL string + OrderImageAlt string + Items []string + StorefrontURL string + ClientDashboardURL string +} + +type deliveredEmailTemplateData struct { + FirstName string + OrderID string + DeliveredDate string + OrderImageURL string + OrderImageAlt string + Items []string + StorefrontURL string +} + +var shippedEmailHTMLTemplate = htmltemplate.Must(htmltemplate.New("shipped-email-html").Parse(` + + +
+
+

Hi {{.FirstName}},

+

Your Royal Pop order is now on the way.

+

+ Order reference: {{.OrderID}}
+ Shipped: {{.ShippedDate}}
+ Carrier: {{.ShippingCarrier}}
+ Tracking number: {{.TrackingNumber}} +

+ {{if .OrderImageURL}} +
+ {{.OrderImageAlt}} +
+ {{end}} + {{if .TrackingURL}} +

+ Track your package +

+ {{end}} +

In this shipment

+
    + {{range .Items}}
  • {{.}}
  • {{end}} +
+

If you need anything, just reply to this email and we’ll help.

+

Royal Pop

+
+
+ +`)) + +var shippedEmailTextTemplate = texttemplate.Must(texttemplate.New("shipped-email-text").Parse(`Hi {{.FirstName}}, + +Your Royal Pop order is now on the way. + +Order reference: {{.OrderID}} +Shipped: {{.ShippedDate}} +Carrier: {{.ShippingCarrier}} +Tracking number: {{.TrackingNumber}} +{{if .TrackingURL}}Track your package: {{.TrackingURL}} + +{{end}} + +In this shipment: +{{range .Items}}- {{.}} +{{end}} + +If you need anything, just reply to this email and we’ll help. + +Royal Pop +`)) + +var cancelledEmailHTMLTemplate = htmltemplate.Must(htmltemplate.New("cancelled-email-html").Parse(` + + +
+
+

Hi {{.FirstName}},

+

Your Royal Pop order has now been cancelled.

+

+ Order reference: {{.OrderID}}
+ Cancelled: {{.CancelledDate}} +

+ {{if .OrderImageURL}} +
+ {{.OrderImageAlt}} +
+ {{end}} +

We were not able to complete this order because some of the information provided was missing or invalid.

+

A refund of {{.RefundAmount}} has now been issued to the original payment method. Depending on your bank or card provider, the refund may take a few business days to appear.

+

This cancelled order included

+
    + {{range .Items}}
  • {{.}}
  • {{end}} +
+

If you would still like the order, just reply to this email and we’ll help you sort it out.

+

Royal Pop

+
+
+ +`)) + +var cancelledEmailTextTemplate = texttemplate.Must(texttemplate.New("cancelled-email-text").Parse(`Hi {{.FirstName}}, + +Your Royal Pop order has now been cancelled. + +Order reference: {{.OrderID}} +Cancelled: {{.CancelledDate}} + +We were not able to complete this order because some of the information provided was missing or invalid. + +A refund of {{.RefundAmount}} has now been issued to the original payment method. Depending on your bank or card provider, the refund may take a few business days to appear. + +This cancelled order included: +{{range .Items}}- {{.}} +{{end}} + +If you would still like the order, just reply to this email and we’ll help you sort it out. + +Royal Pop +`)) + +var paidEmailHTMLTemplate = htmltemplate.Must(htmltemplate.New("paid-email-html").Parse(` + + +
+
+

Hi {{.FirstName}},

+

We’ve received your Royal Pop order and your payment has gone through successfully.

+

+ Order reference: {{.OrderID}}
+ Paid: {{.PaidDate}}
+ Amount received: {{.PaidAmount}} +

+ {{if .OrderImageURL}} +
+ {{.OrderImageAlt}} +
+ {{end}} +

Your order details

+
    + {{range .Items}}
  • {{.}}
  • {{end}} +
+

We’ll email you again as soon as your order has been packed and shipped.

+

If you need anything in the meantime, just reply to this email and we’ll help.

+

Royal Pop

+
+
+ +`)) + +var paidEmailTextTemplate = texttemplate.Must(texttemplate.New("paid-email-text").Parse(`Hi {{.FirstName}}, + +We’ve received your Royal Pop order and your payment has gone through successfully. + +Order reference: {{.OrderID}} +Paid: {{.PaidDate}} +Amount received: {{.PaidAmount}} + +Your order details: +{{range .Items}}- {{.}} +{{end}} + +We’ll email you again as soon as your order has been packed and shipped. + +If you need anything in the meantime, just reply to this email and we’ll help. + +Royal Pop +`)) + +var createdEmailHTMLTemplate = htmltemplate.Must(htmltemplate.New("created-email-html").Parse(` + + +
+
+

Hi,

+

A new paid Royal Pop order has been created and is ready for review in the client dashboard.

+

+ Order reference: {{.OrderID}}
+ Created: {{.CreatedDate}}
+ Order total: {{.OrderAmount}}
+ Customer: {{.CustomerName}}
+ Customer email: {{.CustomerEmail}} +

+ {{if .OrderImageURL}} +
+ {{.OrderImageAlt}} +
+ {{end}} + {{if .ClientDashboardURL}} +

+ Open client dashboard +

+ {{end}} +

Your order details

+
    + {{range .Items}}
  • {{.}}
  • {{end}} +
+

Use the dashboard to review the order, check stock, and continue fulfilment.

+

If you need anything, just reply to this email and we’ll help.

+

Royal Pop

+
+
+ +`)) + +var createdEmailTextTemplate = texttemplate.Must(texttemplate.New("created-email-text").Parse(`Hi, + +A new paid Royal Pop order has been created and is ready for review in the client dashboard. + +Order reference: {{.OrderID}} +Created: {{.CreatedDate}} +Order total: {{.OrderAmount}} +Customer: {{.CustomerName}} +Customer email: {{.CustomerEmail}} + +{{if .ClientDashboardURL}}Open client dashboard: {{.ClientDashboardURL}} + +{{end}} + +Your order details: +{{range .Items}}- {{.}} +{{end}} + +Use the dashboard to review the order, check stock, and continue fulfilment. + +If you need anything, just reply to this email and we’ll help. + +Royal Pop +`)) + +var deliveredEmailHTMLTemplate = htmltemplate.Must(htmltemplate.New("delivered-email-html").Parse(` + + +
+
+

Hi {{.FirstName}},

+

Your Royal Pop order has been delivered.

+

+ Order reference: {{.OrderID}}
+ Delivered: {{.DeliveredDate}} +

+ {{if .OrderImageURL}} +
+ {{.OrderImageAlt}} +
+ {{end}} +

Your delivered order

+
    + {{range .Items}}
  • {{.}}
  • {{end}} +
+

Thank you for your order — we hope you love it.

+

If you need anything at all, just reply to this email and we’ll help.

+

Royal Pop

+
+
+ +`)) + +var deliveredEmailTextTemplate = texttemplate.Must(texttemplate.New("delivered-email-text").Parse(`Hi {{.FirstName}}, + +Your Royal Pop order has been delivered. + +Order reference: {{.OrderID}} +Delivered: {{.DeliveredDate}} + +Your delivered order: +{{range .Items}}- {{.}} +{{end}} + +Thank you for your order — we hope you love it. + +If you need anything at all, just reply to this email and we’ll help. + +Royal Pop +`)) + +var colorwayNames = map[string]string{ + "ocho-negro": "ONYX", + "pure-white": "BLANC", + "pop-pink": "SAKURA", + "racer-green": "FOREST", + "lime-blue": "SAGE", + "deep-blue-orange": "MIDNIGHT", + "light-blue-sprint": "GLACIER", + "sorbet-pop-multi-color": "SORBET", +} + +var finishNames = map[string]string{ + "silver": "Silver", + "black-pvd": "Black PVD", + "rose-gold": "Rose Gold", +} + +func NewResendMailer(cfg *config.Config) *ResendMailer { + if cfg == nil { + return &ResendMailer{} + } + + return &ResendMailer{ + apiKey: strings.TrimSpace(cfg.ResendAPIKey), + from: strings.TrimSpace(cfg.ResendOrderFrom), + replyTo: strings.TrimSpace(cfg.ResendForward), + storefrontURL: normalizeStorefrontURL(cfg.StorefrontURL), + httpClient: &http.Client{ + Timeout: 15 * time.Second, + }, + } +} + +func (m *ResendMailer) Enabled() bool { + if m == nil { + return false + } + + return strings.TrimSpace(m.apiKey) != "" && strings.TrimSpace(m.from) != "" +} + +func (m *ResendMailer) SendOrderShipped(ctx context.Context, order *orders.Order) error { + if order == nil { + return fmt.Errorf("order is required") + } + if !m.Enabled() { + return fmt.Errorf("resend mailer is not configured") + } + + payload, err := m.buildShippedEmailRequest(order) + if err != nil { + return err + } + + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal resend payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, resendEndpoint, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create resend request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+m.apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := m.httpClient.Do(req) + if err != nil { + return fmt.Errorf("send resend request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + + responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + message := strings.TrimSpace(string(responseBody)) + if message == "" { + message = resp.Status + } + + return fmt.Errorf("resend email failed: %s", message) +} + +func (m *ResendMailer) SendOrderCancelled(ctx context.Context, order *orders.Order) error { + if order == nil { + return fmt.Errorf("order is required") + } + if !m.Enabled() { + return fmt.Errorf("resend mailer is not configured") + } + + payload, err := m.buildCancelledEmailRequest(order) + if err != nil { + return err + } + + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal resend payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, resendEndpoint, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create resend request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+m.apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := m.httpClient.Do(req) + if err != nil { + return fmt.Errorf("send resend request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + + responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + message := strings.TrimSpace(string(responseBody)) + if message == "" { + message = resp.Status + } + + return fmt.Errorf("resend email failed: %s", message) +} + +func (m *ResendMailer) SendOrderPaid(ctx context.Context, order *orders.Order) error { + if order == nil { + return fmt.Errorf("order is required") + } + if !m.Enabled() { + return fmt.Errorf("resend mailer is not configured") + } + + payload, err := m.buildPaidEmailRequest(order) + if err != nil { + return err + } + + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal resend payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, resendEndpoint, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create resend request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+m.apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := m.httpClient.Do(req) + if err != nil { + return fmt.Errorf("send resend request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + + responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + message := strings.TrimSpace(string(responseBody)) + if message == "" { + message = resp.Status + } + + return fmt.Errorf("resend email failed: %s", message) +} + +func (m *ResendMailer) SendOrderCreated(ctx context.Context, order *orders.Order) error { + if order == nil { + return fmt.Errorf("order is required") + } + if !m.Enabled() { + return fmt.Errorf("resend mailer is not configured") + } + + payload, err := m.buildCreatedEmailRequest(order) + if err != nil { + return err + } + + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal resend payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, resendEndpoint, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create resend request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+m.apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := m.httpClient.Do(req) + if err != nil { + return fmt.Errorf("send resend request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + + responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + message := strings.TrimSpace(string(responseBody)) + if message == "" { + message = resp.Status + } + + return fmt.Errorf("resend email failed: %s", message) +} + +func (m *ResendMailer) SendOrderDelivered(ctx context.Context, order *orders.Order) error { + if order == nil { + return fmt.Errorf("order is required") + } + if !m.Enabled() { + return fmt.Errorf("resend mailer is not configured") + } + + payload, err := m.buildDeliveredEmailRequest(order) + if err != nil { + return err + } + + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal resend payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, resendEndpoint, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create resend request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+m.apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := m.httpClient.Do(req) + if err != nil { + return fmt.Errorf("send resend request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + + responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + message := strings.TrimSpace(string(responseBody)) + if message == "" { + message = resp.Status + } + + return fmt.Errorf("resend email failed: %s", message) +} + +func (m *ResendMailer) buildShippedEmailRequest(order *orders.Order) (*resendEmailRequest, error) { + data := shippedEmailTemplateData{ + FirstName: firstNonEmpty(strings.TrimSpace(order.FirstName), "there"), + OrderID: strings.TrimSpace(order.ID), + ShippedDate: shippedDateLabel(order), + ShippingCarrier: strings.TrimSpace(order.ShippingCarrier), + TrackingNumber: strings.TrimSpace(order.TrackingNumber), + TrackingURL: buildTrackingURL(order.ShippingCarrier, order.TrackingNumber), + OrderImageURL: buildOrderImageURL(m.storefrontURL, order.Items), + OrderImageAlt: buildOrderImageAlt(order.Items), + Items: buildShipmentLineItems(order.Items), + StorefrontURL: m.storefrontURL, + } + + var htmlBody bytes.Buffer + if err := shippedEmailHTMLTemplate.Execute(&htmlBody, data); err != nil { + return nil, fmt.Errorf("render shipment email html: %w", err) + } + + var textBody bytes.Buffer + if err := shippedEmailTextTemplate.Execute(&textBody, data); err != nil { + return nil, fmt.Errorf("render shipment email text: %w", err) + } + + return &resendEmailRequest{ + From: m.from, + To: []string{strings.TrimSpace(order.Email)}, + Subject: fmt.Sprintf("Your Royal Pop order is on the way — %s", data.OrderID), + HTML: htmlBody.String(), + Text: textBody.String(), + ReplyTo: m.replyTo, + }, nil +} + +func (m *ResendMailer) buildCancelledEmailRequest(order *orders.Order) (*resendEmailRequest, error) { + data := cancelledEmailTemplateData{ + FirstName: firstNonEmpty(strings.TrimSpace(order.FirstName), "there"), + OrderID: strings.TrimSpace(order.ID), + CancelledDate: cancelledDateLabel(order), + RefundAmount: formatMoney(order.Currency, order.Amount), + OrderImageURL: buildOrderImageURL(m.storefrontURL, order.Items), + OrderImageAlt: buildOrderImageAlt(order.Items), + Items: buildShipmentLineItems(order.Items), + StorefrontURL: m.storefrontURL, + } + + var htmlBody bytes.Buffer + if err := cancelledEmailHTMLTemplate.Execute(&htmlBody, data); err != nil { + return nil, fmt.Errorf("render cancelled email html: %w", err) + } + + var textBody bytes.Buffer + if err := cancelledEmailTextTemplate.Execute(&textBody, data); err != nil { + return nil, fmt.Errorf("render cancelled email text: %w", err) + } + + return &resendEmailRequest{ + From: m.from, + To: []string{strings.TrimSpace(order.Email)}, + Subject: fmt.Sprintf("Your Royal Pop order has been cancelled — %s", data.OrderID), + HTML: htmlBody.String(), + Text: textBody.String(), + ReplyTo: m.replyTo, + }, nil +} + +func (m *ResendMailer) buildPaidEmailRequest(order *orders.Order) (*resendEmailRequest, error) { + data := paidEmailTemplateData{ + FirstName: firstNonEmpty(strings.TrimSpace(order.FirstName), "there"), + OrderID: strings.TrimSpace(order.ID), + PaidDate: paidDateLabel(order), + PaidAmount: formatMoney(order.Currency, order.Amount), + OrderImageURL: buildOrderImageURL(m.storefrontURL, order.Items), + OrderImageAlt: buildOrderImageAlt(order.Items), + Items: buildShipmentLineItems(order.Items), + StorefrontURL: m.storefrontURL, + } + + var htmlBody bytes.Buffer + if err := paidEmailHTMLTemplate.Execute(&htmlBody, data); err != nil { + return nil, fmt.Errorf("render paid email html: %w", err) + } + + var textBody bytes.Buffer + if err := paidEmailTextTemplate.Execute(&textBody, data); err != nil { + return nil, fmt.Errorf("render paid email text: %w", err) + } + + return &resendEmailRequest{ + From: m.from, + To: []string{strings.TrimSpace(order.Email)}, + Subject: fmt.Sprintf("We’ve received your Royal Pop order — %s", data.OrderID), + HTML: htmlBody.String(), + Text: textBody.String(), + ReplyTo: m.replyTo, + }, nil +} + +func (m *ResendMailer) buildCreatedEmailRequest(order *orders.Order) (*resendEmailRequest, error) { + data := createdEmailTemplateData{ + OrderID: strings.TrimSpace(order.ID), + CreatedDate: createdDateLabel(order), + OrderAmount: formatMoney(order.Currency, order.Amount), + CustomerName: strings.TrimSpace(strings.Join([]string{strings.TrimSpace(order.FirstName), strings.TrimSpace(order.LastName)}, " ")), + CustomerEmail: strings.TrimSpace(order.Email), + OrderImageURL: buildOrderImageURL(m.storefrontURL, order.Items), + OrderImageAlt: buildOrderImageAlt(order.Items), + Items: buildShipmentLineItems(order.Items), + StorefrontURL: m.storefrontURL, + ClientDashboardURL: buildClientDashboardURL(m.storefrontURL), + } + if data.CustomerName == "" { + data.CustomerName = "Customer" + } + if data.CustomerEmail == "" { + data.CustomerEmail = "Not provided" + } + + var htmlBody bytes.Buffer + if err := createdEmailHTMLTemplate.Execute(&htmlBody, data); err != nil { + return nil, fmt.Errorf("render created email html: %w", err) + } + + var textBody bytes.Buffer + if err := createdEmailTextTemplate.Execute(&textBody, data); err != nil { + return nil, fmt.Errorf("render created email text: %w", err) + } + + return &resendEmailRequest{ + From: m.from, + To: []string{firstNonEmpty(strings.TrimSpace(m.replyTo), strings.TrimSpace(m.from))}, + Subject: fmt.Sprintf("Client reminder: paid order created — %s", data.OrderID), + HTML: htmlBody.String(), + Text: textBody.String(), + ReplyTo: m.replyTo, + }, nil +} + +func (m *ResendMailer) buildDeliveredEmailRequest(order *orders.Order) (*resendEmailRequest, error) { + data := deliveredEmailTemplateData{ + FirstName: firstNonEmpty(strings.TrimSpace(order.FirstName), "there"), + OrderID: strings.TrimSpace(order.ID), + DeliveredDate: deliveredDateLabel(order), + OrderImageURL: buildOrderImageURL(m.storefrontURL, order.Items), + OrderImageAlt: buildOrderImageAlt(order.Items), + Items: buildShipmentLineItems(order.Items), + StorefrontURL: m.storefrontURL, + } + + var htmlBody bytes.Buffer + if err := deliveredEmailHTMLTemplate.Execute(&htmlBody, data); err != nil { + return nil, fmt.Errorf("render delivered email html: %w", err) + } + + var textBody bytes.Buffer + if err := deliveredEmailTextTemplate.Execute(&textBody, data); err != nil { + return nil, fmt.Errorf("render delivered email text: %w", err) + } + + return &resendEmailRequest{ + From: m.from, + To: []string{strings.TrimSpace(order.Email)}, + Subject: fmt.Sprintf("Your Royal Pop order has been delivered — %s", data.OrderID), + HTML: htmlBody.String(), + Text: textBody.String(), + ReplyTo: m.replyTo, + }, nil +} + +func buildClientDashboardURL(storefrontURL string) string { + base := strings.TrimRight(strings.TrimSpace(storefrontURL), "/") + if base == "" { + base = "https://royal-pop-accessory.com" + } + + return base + "/client" +} + +func buildShipmentLineItems(items []orders.LineItem) []string { + if len(items) == 0 { + return []string{"Royal Pop order items"} + } + + lines := make([]string, 0, len(items)) + for _, item := range items { + quantity := item.Quantity + if quantity <= 0 { + quantity = 1 + } + + styleLabel := "Style " + strings.ToUpper(strings.TrimSpace(item.Style)) + if strings.TrimSpace(item.Style) == "" { + styleLabel = "Style" + } + + lines = append(lines, fmt.Sprintf( + "%s · %s · %s · %s", + firstNonEmpty(colorwayDisplayName(item.ColorwayID), "Royal Pop"), + styleLabel, + firstNonEmpty(finishDisplayName(item.FinishID), "Finish"), + quantityLabel(quantity), + )) + } + + return lines +} + +func shippedDateLabel(order *orders.Order) string { + if order == nil { + return time.Now().UTC().Format("2 January 2006") + } + + if order.ShippedAt != nil && !order.ShippedAt.IsZero() { + return order.ShippedAt.UTC().Format("2 January 2006") + } + + if !order.UpdatedAt.IsZero() { + return order.UpdatedAt.UTC().Format("2 January 2006") + } + + return time.Now().UTC().Format("2 January 2006") +} + +func cancelledDateLabel(order *orders.Order) string { + if order == nil { + return time.Now().UTC().Format("2 January 2006") + } + + if !order.UpdatedAt.IsZero() { + return order.UpdatedAt.UTC().Format("2 January 2006") + } + + return time.Now().UTC().Format("2 January 2006") +} + +func paidDateLabel(order *orders.Order) string { + if order == nil { + return time.Now().UTC().Format("2 January 2006") + } + + if !order.UpdatedAt.IsZero() { + return order.UpdatedAt.UTC().Format("2 January 2006") + } + + if !order.CreatedAt.IsZero() { + return order.CreatedAt.UTC().Format("2 January 2006") + } + + return time.Now().UTC().Format("2 January 2006") +} + +func createdDateLabel(order *orders.Order) string { + if order == nil { + return time.Now().UTC().Format("2 January 2006") + } + + if !order.CreatedAt.IsZero() { + return order.CreatedAt.UTC().Format("2 January 2006") + } + + if !order.UpdatedAt.IsZero() { + return order.UpdatedAt.UTC().Format("2 January 2006") + } + + return time.Now().UTC().Format("2 January 2006") +} + +func deliveredDateLabel(order *orders.Order) string { + if order == nil { + return time.Now().UTC().Format("2 January 2006") + } + + if !order.UpdatedAt.IsZero() { + return order.UpdatedAt.UTC().Format("2 January 2006") + } + + if order.ShippedAt != nil && !order.ShippedAt.IsZero() { + return order.ShippedAt.UTC().Format("2 January 2006") + } + + return time.Now().UTC().Format("2 January 2006") +} + +func quantityLabel(quantity int64) string { + if quantity == 1 { + return "1 kit" + } + + return fmt.Sprintf("%d kits", quantity) +} + +func formatMoney(currency string, amount int64) string { + code := strings.ToUpper(strings.TrimSpace(currency)) + major := float64(amount) / 100 + + switch code { + case "GBP", "": + return fmt.Sprintf("£%.2f", major) + case "USD": + return fmt.Sprintf("$%.2f", major) + case "EUR": + return fmt.Sprintf("€%.2f", major) + default: + return fmt.Sprintf("%s %.2f", code, major) + } +} + +func colorwayDisplayName(id string) string { + trimmed := strings.TrimSpace(id) + if trimmed == "" { + return "" + } + if label, ok := colorwayNames[trimmed]; ok { + return label + } + + return slugLabel(trimmed) +} + +func finishDisplayName(id string) string { + trimmed := strings.TrimSpace(id) + if trimmed == "" { + return "" + } + if label, ok := finishNames[trimmed]; ok { + return label + } + + return slugLabel(trimmed) +} + +func slugLabel(value string) string { + parts := strings.Fields(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(value), "-", " "), "_", " ")) + if len(parts) == 0 { + return "" + } + + formatted := make([]string, 0, len(parts)) + for _, part := range parts { + formatted = append(formatted, strings.ToUpper(part[:1])+strings.ToLower(part[1:])) + } + + return strings.Join(formatted, " ") +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + + return "" +} + +func normalizeStorefrontURL(raw string) string { + trimmed := strings.TrimRight(strings.TrimSpace(raw), "/") + if trimmed == "" { + return "https://royal-pop-accessory.com" + } + + lower := strings.ToLower(trimmed) + if strings.Contains(lower, "localhost") || strings.Contains(lower, "127.0.0.1") || strings.Contains(lower, ".local") { + return "https://royal-pop-accessory.com" + } + + return trimmed +} + +func buildTrackingURL(carrier, trackingNumber string) string { + tracking := strings.TrimSpace(trackingNumber) + if tracking == "" { + return "" + } + + return "https://www.17track.net/en/track#nums=" + url.QueryEscape(tracking) +} + +func buildOrderImageURL(storefrontURL string, items []orders.LineItem) string { + baseURL := strings.TrimRight(strings.TrimSpace(storefrontURL), "/") + if baseURL == "" { + return "" + } + + imagePath := "/images/png/ocho-negro.png" + if len(items) > 0 { + colorwayID := strings.TrimSpace(items[0].ColorwayID) + if mappedPath, ok := orderImagePathForColorway(colorwayID); ok { + imagePath = mappedPath + } + } + + return baseURL + imagePath +} + +func orderImagePathForColorway(colorwayID string) (string, bool) { + switch strings.TrimSpace(colorwayID) { + case "ocho-negro": + return "/images/png/ocho-negro.png", true + case "pure-white": + return "/images/png/pure-white.png", true + case "pop-pink": + return "/images/png/pop-pink.png", true + case "racer-green": + return "/images/png/racer-green.png", true + case "lime-blue": + return "/images/png/lime-blue.png", true + case "deep-blue-orange": + return "/images/png/deep-blue.png", true + case "light-blue-sprint": + return "/images/png/light-blue.png", true + case "sorbet-pop-multi-color": + return "/images/png/sorbet-multi.png", true + default: + return "", false + } +} + +func buildOrderImageAlt(items []orders.LineItem) string { + if len(items) == 0 { + return "Royal Pop order preview" + } + + colorway := colorwayDisplayName(items[0].ColorwayID) + style := strings.ToUpper(strings.TrimSpace(items[0].Style)) + finish := finishDisplayName(items[0].FinishID) + + parts := make([]string, 0, 3) + if colorway != "" { + parts = append(parts, colorway) + } + if style != "" { + parts = append(parts, "Style "+style) + } + if finish != "" { + parts = append(parts, finish) + } + + if len(parts) == 0 { + return "Royal Pop order preview" + } + + return strings.Join(parts, " · ") + " order preview" +} diff --git a/Backend/internal/orders/store.go b/Backend/internal/orders/store.go new file mode 100644 index 0000000..6a20d37 --- /dev/null +++ b/Backend/internal/orders/store.go @@ -0,0 +1,657 @@ +package orders + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" + + "royal-pop-backend/internal/database" +) + +var ErrOrderNotFound = errors.New("order not found") + +const ( + StatusPending = "pending" + StatusPaid = "paid" + StatusFailed = "failed" + StatusProcessing = "processing" + StatusCanceled = "canceled" + + FulfillmentPending = "pending" + FulfillmentPaid = "paid" + FulfillmentProcessing = "processing" + FulfillmentPacked = "packed" + FulfillmentShipped = "shipped" + FulfillmentDelivered = "delivered" + FulfillmentCancelled = "cancelled" +) + +type CustomerDetails struct { + Email string + Phone string + FirstName string + LastName string + AddressLine1 string + AddressLine2 string + City string + Region string + PostalCode string + Country string + Notes string +} + +type LineItem struct { + ItemID string + Style string + ColorwayID string + FinishID string + Quantity int64 + UnitAmount int64 +} + +type CreateOrderInput struct { + OrderID string + Customer CustomerDetails + Items []LineItem + Currency string + Amount int64 + StripePriceID string + StripePaymentIntentID string +} + +type Order struct { + ID string + Status string + FulfillmentStatus string + Email string + Phone string + FirstName string + LastName string + AddressLine1 string + AddressLine2 string + City string + Region string + PostalCode string + Country string + Notes string + Currency string + Amount int64 + StripePriceID string + StripePaymentIntentID string + ShippingCarrier string + TrackingNumber string + FulfillmentNotes string + ShippedAt *time.Time + WebhookStatus string + WebhookEventID string + WebhookEventType string + WebhookMessage string + Items []LineItem + CreatedAt time.Time + UpdatedAt time.Time +} + +type WebhookUpdate struct { + Status string + WebhookStatus string + WebhookEventID string + WebhookEventType string + WebhookMessage string +} + +type ListFilter struct { + Limit int + Search string + FulfillmentStatus string +} + +type UpdateFulfillmentInput struct { + OrderID string + FulfillmentStatus string + ShippingCarrier string + TrackingNumber string + FulfillmentNotes string + ClearShippedAt bool + MarkShippedAtNow bool +} + +type Store struct { + db *database.DB +} + +func NewStore(db *database.DB) *Store { + return &Store{db: db} +} + +func (s *Store) CreatePendingOrder(ctx context.Context, input CreateOrderInput) (*Order, error) { + if s == nil || s.db == nil || s.db.Pool == nil { + return nil, fmt.Errorf("postgres store is not configured") + } + + tx, err := s.db.Pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + _, err = tx.Exec(ctx, ` + INSERT INTO orders ( + id, status, email, phone, first_name, last_name, + address_line_1, address_line_2, city, region, postal_code, country, notes, + currency, amount, stripe_price_id, stripe_payment_intent_id, + webhook_status, webhook_message + ) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, $10, $11, $12, $13, + $14, $15, $16, $17, + $18, $19 + ) + `, + input.OrderID, + StatusPending, + input.Customer.Email, + input.Customer.Phone, + input.Customer.FirstName, + input.Customer.LastName, + input.Customer.AddressLine1, + input.Customer.AddressLine2, + input.Customer.City, + input.Customer.Region, + input.Customer.PostalCode, + input.Customer.Country, + input.Customer.Notes, + input.Currency, + input.Amount, + input.StripePriceID, + input.StripePaymentIntentID, + "awaiting_webhook", + "Waiting for Stripe webhook verification.", + ) + if err != nil { + return nil, err + } + + for _, item := range input.Items { + quantity := item.Quantity + if quantity <= 0 { + quantity = 1 + } + + _, err = tx.Exec(ctx, ` + INSERT INTO order_items (order_id, item_id, style, colorway_id, finish_id, quantity, unit_amount) + VALUES ($1, $2, $3, $4, $5, $6, $7) + `, input.OrderID, item.ItemID, item.Style, item.ColorwayID, item.FinishID, quantity, item.UnitAmount) + if err != nil { + return nil, err + } + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + return s.GetByPaymentIntentID(ctx, input.StripePaymentIntentID) +} + +func (s *Store) GetByPaymentIntentID(ctx context.Context, paymentIntentID string) (*Order, error) { + if s == nil || s.db == nil || s.db.Pool == nil { + return nil, fmt.Errorf("postgres store is not configured") + } + + row := s.db.Pool.QueryRow(ctx, ` + SELECT id, status, fulfillment_status, email, phone, first_name, last_name, + address_line_1, address_line_2, city, region, postal_code, country, notes, + currency, amount, stripe_price_id, stripe_payment_intent_id, + shipping_carrier, tracking_number, fulfillment_notes, shipped_at, + webhook_status, webhook_event_id, webhook_event_type, webhook_message, + created_at, updated_at + FROM orders + WHERE stripe_payment_intent_id = $1 + `, paymentIntentID) + + var order Order + var shippedAt sql.NullTime + if err := row.Scan( + &order.ID, + &order.Status, + &order.FulfillmentStatus, + &order.Email, + &order.Phone, + &order.FirstName, + &order.LastName, + &order.AddressLine1, + &order.AddressLine2, + &order.City, + &order.Region, + &order.PostalCode, + &order.Country, + &order.Notes, + &order.Currency, + &order.Amount, + &order.StripePriceID, + &order.StripePaymentIntentID, + &order.ShippingCarrier, + &order.TrackingNumber, + &order.FulfillmentNotes, + &shippedAt, + &order.WebhookStatus, + &order.WebhookEventID, + &order.WebhookEventType, + &order.WebhookMessage, + &order.CreatedAt, + &order.UpdatedAt, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrOrderNotFound + } + return nil, err + } + if shippedAt.Valid { + order.ShippedAt = &shippedAt.Time + } + + items, err := s.listItemsByOrderID(ctx, order.ID) + if err != nil { + return nil, err + } + order.Items = items + + return &order, nil +} + +func (s *Store) ListRecent(ctx context.Context, limit int) ([]Order, error) { + if s == nil || s.db == nil || s.db.Pool == nil { + return nil, fmt.Errorf("postgres store is not configured") + } + + if limit <= 0 { + limit = 25 + } + if limit > 100 { + limit = 100 + } + + rows, err := s.db.Pool.Query(ctx, ` + SELECT id, status, fulfillment_status, email, phone, first_name, last_name, + address_line_1, address_line_2, city, region, postal_code, country, notes, + currency, amount, stripe_price_id, stripe_payment_intent_id, + shipping_carrier, tracking_number, fulfillment_notes, shipped_at, + webhook_status, webhook_event_id, webhook_event_type, webhook_message, + created_at, updated_at + FROM orders + ORDER BY created_at DESC + LIMIT $1 + `, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + ordersList := make([]Order, 0, limit) + for rows.Next() { + var order Order + var shippedAt sql.NullTime + if err := rows.Scan( + &order.ID, + &order.Status, + &order.FulfillmentStatus, + &order.Email, + &order.Phone, + &order.FirstName, + &order.LastName, + &order.AddressLine1, + &order.AddressLine2, + &order.City, + &order.Region, + &order.PostalCode, + &order.Country, + &order.Notes, + &order.Currency, + &order.Amount, + &order.StripePriceID, + &order.StripePaymentIntentID, + &order.ShippingCarrier, + &order.TrackingNumber, + &order.FulfillmentNotes, + &shippedAt, + &order.WebhookStatus, + &order.WebhookEventID, + &order.WebhookEventType, + &order.WebhookMessage, + &order.CreatedAt, + &order.UpdatedAt, + ); err != nil { + return nil, err + } + if shippedAt.Valid { + order.ShippedAt = &shippedAt.Time + } + + items, err := s.listItemsByOrderID(ctx, order.ID) + if err != nil { + return nil, err + } + order.Items = items + ordersList = append(ordersList, order) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return ordersList, nil +} + +func (s *Store) ListForDashboard(ctx context.Context, filter ListFilter) ([]Order, error) { + if s == nil || s.db == nil || s.db.Pool == nil { + return nil, fmt.Errorf("postgres store is not configured") + } + + limit := filter.Limit + if limit <= 0 { + limit = 50 + } + if limit > 200 { + limit = 200 + } + + search := strings.TrimSpace(filter.Search) + fulfillmentStatus := strings.TrimSpace(strings.ToLower(filter.FulfillmentStatus)) + + rows, err := s.db.Pool.Query(ctx, ` + SELECT id, status, fulfillment_status, email, phone, first_name, last_name, + address_line_1, address_line_2, city, region, postal_code, country, notes, + currency, amount, stripe_price_id, stripe_payment_intent_id, + shipping_carrier, tracking_number, fulfillment_notes, shipped_at, + webhook_status, webhook_event_id, webhook_event_type, webhook_message, + created_at, updated_at + FROM orders + WHERE ($1 = '' OR fulfillment_status = $1) + AND ( + $2 = '' + OR id ILIKE '%' || $2 || '%' + OR first_name ILIKE '%' || $2 || '%' + OR last_name ILIKE '%' || $2 || '%' + OR email ILIKE '%' || $2 || '%' + OR tracking_number ILIKE '%' || $2 || '%' + ) + ORDER BY created_at DESC + LIMIT $3 + `, fulfillmentStatus, search, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + ordersList := make([]Order, 0, limit) + for rows.Next() { + var order Order + var shippedAt sql.NullTime + if err := rows.Scan( + &order.ID, + &order.Status, + &order.FulfillmentStatus, + &order.Email, + &order.Phone, + &order.FirstName, + &order.LastName, + &order.AddressLine1, + &order.AddressLine2, + &order.City, + &order.Region, + &order.PostalCode, + &order.Country, + &order.Notes, + &order.Currency, + &order.Amount, + &order.StripePriceID, + &order.StripePaymentIntentID, + &order.ShippingCarrier, + &order.TrackingNumber, + &order.FulfillmentNotes, + &shippedAt, + &order.WebhookStatus, + &order.WebhookEventID, + &order.WebhookEventType, + &order.WebhookMessage, + &order.CreatedAt, + &order.UpdatedAt, + ); err != nil { + return nil, err + } + if shippedAt.Valid { + order.ShippedAt = &shippedAt.Time + } + + items, err := s.listItemsByOrderID(ctx, order.ID) + if err != nil { + return nil, err + } + order.Items = items + ordersList = append(ordersList, order) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return ordersList, nil +} + +func (s *Store) GetByID(ctx context.Context, orderID string) (*Order, error) { + if s == nil || s.db == nil || s.db.Pool == nil { + return nil, fmt.Errorf("postgres store is not configured") + } + + row := s.db.Pool.QueryRow(ctx, ` + SELECT id, status, fulfillment_status, email, phone, first_name, last_name, + address_line_1, address_line_2, city, region, postal_code, country, notes, + currency, amount, stripe_price_id, stripe_payment_intent_id, + shipping_carrier, tracking_number, fulfillment_notes, shipped_at, + webhook_status, webhook_event_id, webhook_event_type, webhook_message, + created_at, updated_at + FROM orders + WHERE id = $1 + `, strings.TrimSpace(orderID)) + + var order Order + var shippedAt sql.NullTime + if err := row.Scan( + &order.ID, + &order.Status, + &order.FulfillmentStatus, + &order.Email, + &order.Phone, + &order.FirstName, + &order.LastName, + &order.AddressLine1, + &order.AddressLine2, + &order.City, + &order.Region, + &order.PostalCode, + &order.Country, + &order.Notes, + &order.Currency, + &order.Amount, + &order.StripePriceID, + &order.StripePaymentIntentID, + &order.ShippingCarrier, + &order.TrackingNumber, + &order.FulfillmentNotes, + &shippedAt, + &order.WebhookStatus, + &order.WebhookEventID, + &order.WebhookEventType, + &order.WebhookMessage, + &order.CreatedAt, + &order.UpdatedAt, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrOrderNotFound + } + return nil, err + } + if shippedAt.Valid { + order.ShippedAt = &shippedAt.Time + } + + items, err := s.listItemsByOrderID(ctx, order.ID) + if err != nil { + return nil, err + } + order.Items = items + + return &order, nil +} + +func (s *Store) UpdateFulfillment(ctx context.Context, input UpdateFulfillmentInput) (*Order, error) { + if s == nil || s.db == nil || s.db.Pool == nil { + return nil, fmt.Errorf("postgres store is not configured") + } + + status := normalizeFulfillmentStatus(input.FulfillmentStatus) + if status == "" { + return nil, fmt.Errorf("invalid fulfillment status") + } + + trimmedOrderID := strings.TrimSpace(input.OrderID) + shippingCarrier := strings.TrimSpace(input.ShippingCarrier) + trackingNumber := strings.TrimSpace(input.TrackingNumber) + fulfillmentNotes := strings.TrimSpace(input.FulfillmentNotes) + + var shippedAt any + if input.MarkShippedAtNow { + shippedAt = time.Now().UTC() + } + + commandTag, err := s.db.Pool.Exec(ctx, ` + UPDATE orders + SET fulfillment_status = $2, + shipping_carrier = $3, + tracking_number = $4, + fulfillment_notes = $5, + shipped_at = CASE + WHEN $6::timestamptz IS NOT NULL THEN $6::timestamptz + WHEN $7 THEN NULL + ELSE shipped_at + END, + updated_at = NOW() + WHERE id = $1 + `, trimmedOrderID, status, shippingCarrier, trackingNumber, fulfillmentNotes, shippedAt, input.ClearShippedAt) + if err != nil { + return nil, err + } + if commandTag.RowsAffected() == 0 { + return nil, ErrOrderNotFound + } + + return s.GetByID(ctx, trimmedOrderID) +} + +func (s *Store) UpdateFromWebhook(ctx context.Context, paymentIntentID string, update WebhookUpdate) error { + if s == nil || s.db == nil || s.db.Pool == nil { + return fmt.Errorf("postgres store is not configured") + } + + fulfillmentStatus := "" + switch update.Status { + case StatusPaid: + fulfillmentStatus = FulfillmentPaid + case StatusFailed, StatusCanceled: + fulfillmentStatus = FulfillmentCancelled + } + + commandTag, err := s.db.Pool.Exec(ctx, ` + UPDATE orders + SET status = $2, + fulfillment_status = CASE + WHEN $7 <> '' AND (fulfillment_status = '' OR fulfillment_status = $8 OR fulfillment_status = $9) + THEN $7 + ELSE fulfillment_status + END, + webhook_status = $3, + webhook_event_id = $4, + webhook_event_type = $5, + webhook_message = $6, + updated_at = NOW() + WHERE stripe_payment_intent_id = $1 + `, paymentIntentID, update.Status, update.WebhookStatus, update.WebhookEventID, update.WebhookEventType, update.WebhookMessage, fulfillmentStatus, FulfillmentPending, FulfillmentPaid) + if err != nil { + return err + } + + if commandTag.RowsAffected() == 0 { + return ErrOrderNotFound + } + + return nil +} + +func NewOrderID() string { + raw := make([]byte, 8) + _, _ = rand.Read(raw) + return "rpo_" + hex.EncodeToString(raw) +} + +func (s *Store) listItemsByOrderID(ctx context.Context, orderID string) ([]LineItem, error) { + rows, err := s.db.Pool.Query(ctx, ` + SELECT item_id, style, colorway_id, finish_id, quantity, unit_amount + FROM order_items + WHERE order_id = $1 + ORDER BY id ASC + `, orderID) + if err != nil { + return nil, err + } + defer rows.Close() + + items := make([]LineItem, 0) + for rows.Next() { + var item LineItem + if err := rows.Scan( + &item.ItemID, + &item.Style, + &item.ColorwayID, + &item.FinishID, + &item.Quantity, + &item.UnitAmount, + ); err != nil { + return nil, err + } + items = append(items, item) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return items, nil +} + +func normalizeFulfillmentStatus(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case FulfillmentPending: + return FulfillmentPending + case FulfillmentPaid: + return FulfillmentPaid + case FulfillmentProcessing: + return FulfillmentProcessing + case FulfillmentPacked: + return FulfillmentPacked + case FulfillmentShipped: + return FulfillmentShipped + case FulfillmentDelivered: + return FulfillmentDelivered + case FulfillmentCancelled, StatusCanceled: + return FulfillmentCancelled + default: + return "" + } +} diff --git a/Commands/Local/Dev/backend.just b/Commands/Local/Dev/backend.just new file mode 100644 index 0000000..02e9e0b --- /dev/null +++ b/Commands/Local/Dev/backend.just @@ -0,0 +1,18 @@ +project_root := justfile_directory() +backend_dir := project_root + "/Backend" + +# Format backend Go source files. +fmt: + cd '{{backend_dir}}' && gofmt -w ./cmd ./internal + +# Run backend test suite. +test: + cd '{{backend_dir}}' && go test ./... + +# Open a psql shell into the local Royal Pop database container. +psql: + docker compose -f '{{project_root}}/Docker/docker-compose.local.dev.yaml' exec postgres psql -U royalpop -d royalpop + +# Run backend module cleanup. +tidy: + cd '{{backend_dir}}' && go mod tidy diff --git a/Commands/Local/Dev/frontend.just b/Commands/Local/Dev/frontend.just new file mode 100644 index 0000000..425b562 --- /dev/null +++ b/Commands/Local/Dev/frontend.just @@ -0,0 +1,26 @@ +project_root := justfile_directory() +store_dir := project_root + "/Store" +local_compose := project_root + "/Docker/docker-compose.local.dev.yaml" +node_modules_volume := "royal_pop_store_node_modules" + +# Recreate the storefront node_modules Docker volume. +node_modules: + docker compose -f '{{local_compose}}' rm -sf store >/dev/null 2>&1 || true + docker volume rm -f '{{node_modules_volume}}' >/dev/null 2>&1 || true + docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate store + +# Build the storefront. +build: + cd '{{store_dir}}' && npm run build + +# Print the local client dashboard URL. +client-url: + printf '%s\n' 'Client dashboard: http://localhost:4321/client/' + +# Print the local client orders URL. +client-orders-url: + printf '%s\n' 'Client orders: http://localhost:4321/client/orders/' + +# Print the local client stock URL. +client-stock-url: + printf '%s\n' 'Client stock: http://localhost:4321/client/stock/' diff --git a/Commands/Local/Dev/mod.just b/Commands/Local/Dev/mod.just new file mode 100644 index 0000000..73cfa4e --- /dev/null +++ b/Commands/Local/Dev/mod.just @@ -0,0 +1,40 @@ +project_root := justfile_directory() +backend_dir := project_root + "/Backend" +backend_bake := project_root + "/Backend/docker-bake.hcl" +store_dir := project_root + "/Store" +store_bake := project_root + "/Store/docker-bake.hcl" +local_compose := project_root + "/Docker/docker-compose.local.dev.yaml" + +mod frontend +mod backend +mod stripe + +# Default flow: rebuild the local development images and recreate the stack. +rebuild: + cd '{{backend_dir}}' && docker buildx bake -f '{{backend_bake}}' dev + cd '{{store_dir}}' && docker buildx bake -f '{{store_bake}}' dev + docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate + +# Build the local development images. +build: + cd '{{backend_dir}}' && docker buildx bake -f '{{backend_bake}}' dev + cd '{{store_dir}}' && docker buildx bake -f '{{store_bake}}' dev + +# Start the local development stack in the background using the current image. +up: + docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate + +# Build first, then start the local development stack in the background. +start: build up + +# Stop and remove the local development stack. +down: + docker compose -f '{{local_compose}}' down --remove-orphans --volumes + +# Follow logs for the local development stack. +logs: + docker compose -f '{{local_compose}}' logs -f + +# Restart the local development stack. +restart: + docker compose -f '{{local_compose}}' restart diff --git a/Commands/Local/Dev/stripe.just b/Commands/Local/Dev/stripe.just new file mode 100644 index 0000000..c7ba907 --- /dev/null +++ b/Commands/Local/Dev/stripe.just @@ -0,0 +1,6 @@ +project_root := justfile_directory() +backend_webhook_url := "http://localhost:8081/webhooks/stripe" + +# Forward Stripe test webhooks to the local API. +listen: + stripe listen --forward-to '{{backend_webhook_url}}' diff --git a/Commands/Local/mod.just b/Commands/Local/mod.just new file mode 100644 index 0000000..fa8ae0f --- /dev/null +++ b/Commands/Local/mod.just @@ -0,0 +1,2 @@ +mod dev "Dev" +mod prod diff --git a/Commands/Local/prod.just b/Commands/Local/prod.just new file mode 100644 index 0000000..159b681 --- /dev/null +++ b/Commands/Local/prod.just @@ -0,0 +1,41 @@ +project_root := justfile_directory() +env_file := project_root + "/Env/.env.production" +backend_dir := project_root + "/Backend" +backend_bake := project_root + "/Backend/docker-bake.hcl" +proxy_bake := project_root + "/Proxy/docker-bake.hcl" +local_compose := project_root + "/Docker/docker-compose.local.prod.yaml" +api_image := "goko/royal-pop/api:local-prod" +proxy_image := "goko/royal-pop/proxy:local-prod" + +# Default flow: rebuild the local production images and recreate the full stack. +rebuild: + set -a && source '{{env_file}}' && set +a && cd '{{backend_dir}}' && docker buildx bake -f '{{backend_bake}}' --set '*.no-cache=true' prod && cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' --set '*.no-cache=true' prod && docker compose --env-file '{{env_file}}' -f '{{local_compose}}' up -d --remove-orphans --force-recreate + +# Build the local production API and proxy images. +build: + set -a && source '{{env_file}}' && set +a && cd '{{backend_dir}}' && docker buildx bake -f '{{backend_bake}}' prod && cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' prod + +# Start the local production stack in the background using the current image. +up: + docker compose --env-file '{{env_file}}' -f '{{local_compose}}' up -d --remove-orphans --force-recreate + +# Build first, then start the local production stack in the background. +start: build up + +# Stop and remove the local production stack. +down: + docker compose --env-file '{{env_file}}' -f '{{local_compose}}' down --remove-orphans --volumes + +# Follow logs for the local production stack. +logs: + docker compose --env-file '{{env_file}}' -f '{{local_compose}}' logs -f + +# Restart the local production stack. +restart: + docker compose --env-file '{{env_file}}' -f '{{local_compose}}' restart + +# Stop the local production stack and remove local images. +clean: + docker compose --env-file '{{env_file}}' -f '{{local_compose}}' down --remove-orphans --volumes + docker image rm -f '{{api_image}}' >/dev/null 2>&1 || true + docker image rm -f '{{proxy_image}}' >/dev/null 2>&1 || true diff --git a/Commands/Remote/dev.just b/Commands/Remote/dev.just new file mode 100644 index 0000000..a66173c --- /dev/null +++ b/Commands/Remote/dev.just @@ -0,0 +1,38 @@ +project_root := justfile_directory() +env_file := project_root + "/Env/.env.local" +backend_dir := project_root + "/Backend" +backend_bake := project_root + "/Backend/docker-bake.hcl" +store_dir := project_root + "/Store" +store_bake := project_root + "/Store/docker-bake.hcl" +compose_file := project_root + "/Docker/docker-compose.remote.dev.yaml" + +# Default flow: refresh the registry images and recreate the stack. +rebuild: up + +# Pull the latest remote development images from the registry. +pull: + docker compose --env-file '{{env_file}}' -f '{{compose_file}}' pull + +# Start the remote development stack locally using the current registry images. +up: pull + docker compose --env-file '{{env_file}}' -f '{{compose_file}}' up -d --remove-orphans --force-recreate + +# Publish the remote development images to the registry. +push: + set -a && source '{{env_file}}' && set +a && cd '{{backend_dir}}' && docker buildx bake -f '{{backend_bake}}' dev-image && cd '{{store_dir}}' && docker buildx bake -f '{{store_bake}}' dev-image + +# Stop and remove the remote development stack. +down: + docker compose --env-file '{{env_file}}' -f '{{compose_file}}' down --remove-orphans --volumes + +# Follow logs for the remote development stack. +logs: + docker compose --env-file '{{env_file}}' -f '{{compose_file}}' logs -f + +# Restart the remote development stack. +restart: + docker compose --env-file '{{env_file}}' -f '{{compose_file}}' restart + +# Show remote development stack status. +ps: + docker compose --env-file '{{env_file}}' -f '{{compose_file}}' ps diff --git a/Commands/Remote/mod.just b/Commands/Remote/mod.just new file mode 100644 index 0000000..296865a --- /dev/null +++ b/Commands/Remote/mod.just @@ -0,0 +1,2 @@ +mod dev +mod prod diff --git a/Commands/Remote/prod.just b/Commands/Remote/prod.just new file mode 100644 index 0000000..5f9c78b --- /dev/null +++ b/Commands/Remote/prod.just @@ -0,0 +1,37 @@ +project_root := justfile_directory() +env_file := project_root + "/Env/.env.production" +backend_dir := project_root + "/Backend" +backend_bake := project_root + "/Backend/docker-bake.hcl" +proxy_bake := project_root + "/Proxy/docker-bake.hcl" +compose_file := project_root + "/Docker/docker-compose.remote.prod.yaml" + +# Default flow: refresh the registry images and recreate the stack. +rebuild: up + +# Pull the latest remote production images from the registry. +pull: + docker compose --env-file '{{env_file}}' -f '{{compose_file}}' pull + +# Start the remote production stack locally using the current registry images. +up: pull + docker compose --env-file '{{env_file}}' -f '{{compose_file}}' up -d --remove-orphans --force-recreate + +# Publish the remote production images to the registry. +push: + set -a && source '{{env_file}}' && set +a && cd '{{backend_dir}}' && docker buildx bake -f '{{backend_bake}}' prod-image && cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' prod-image + +# Stop and remove the remote production stack. +down: + docker compose --env-file '{{env_file}}' -f '{{compose_file}}' down --remove-orphans + +# Follow logs for the remote production stack. +logs: + docker compose --env-file '{{env_file}}' -f '{{compose_file}}' logs -f + +# Restart the remote production stack. +restart: + docker compose --env-file '{{env_file}}' -f '{{compose_file}}' restart + +# Show remote production stack status. +ps: + docker compose --env-file '{{env_file}}' -f '{{compose_file}}' ps diff --git a/Docker/docker-compose.local.dev.yaml b/Docker/docker-compose.local.dev.yaml new file mode 100644 index 0000000..f649219 --- /dev/null +++ b/Docker/docker-compose.local.dev.yaml @@ -0,0 +1,43 @@ +services: + postgres: + image: postgres:16-alpine + container_name: royal-pop-postgres + restart: unless-stopped + environment: + POSTGRES_DB: royalpop + POSTGRES_USER: royalpop + POSTGRES_PASSWORD: royalpop_dev_password + ports: + - "5432:5432" + volumes: + - royal_pop_postgres_data:/var/lib/postgresql/data + api: + image: goko/royal-pop/api:dev + container_name: royal-pop-api + restart: unless-stopped + env_file: + - ../Env/.env.local + environment: + DATABASE_URL: postgres://royalpop:royalpop_dev_password@postgres:5432/royalpop?sslmode=disable + ports: + - "8081:8081" + depends_on: + - postgres + volumes: + - ../Backend:/app + store: + image: goko/royal-pop/store:dev + container_name: royal-pop-store + restart: unless-stopped + env_file: + - ../Env/.env.local + environment: + API_BASE_URL: http://localhost:8081 + ports: + - "4321:4321" + volumes: + - ../Store:/app + - store_node_modules:/app/node_modules +volumes: + royal_pop_postgres_data: + store_node_modules: diff --git a/Docker/docker-compose.local.prod.yaml b/Docker/docker-compose.local.prod.yaml new file mode 100644 index 0000000..c31e74c --- /dev/null +++ b/Docker/docker-compose.local.prod.yaml @@ -0,0 +1,43 @@ +name: royal-pop-accessory + +services: + postgres: + image: postgres:16-alpine + container_name: royal-pop-postgres + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-royalpop} + POSTGRES_USER: ${POSTGRES_USER:-royalpop} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-royalpop_change_me} + volumes: + - royal_pop_postgres_data:/var/lib/postgresql/data + + api: + image: goko/royal-pop/api:local-prod + container_name: royal-pop-api + restart: unless-stopped + env_file: + - ../Env/.env.production + environment: + DATABASE_URL: ${DATABASE_URL:-postgres://royalpop:${POSTGRES_PASSWORD:-royalpop_change_me}@postgres:5432/royalpop?sslmode=disable} + expose: + - "8081" + depends_on: + - postgres + + proxy: + image: goko/royal-pop/proxy:local-prod + container_name: royal-pop-proxy + restart: unless-stopped + depends_on: + api: + condition: service_started + ports: + - "80:80" + - "443:443" + volumes: + - ../Env/origin.crt:/etc/nginx/certs/origin.crt:ro + - ../Env/origin.key:/etc/nginx/certs/origin.key:ro + +volumes: + royal_pop_postgres_data: diff --git a/Docker/docker-compose.remote.dev.yaml b/Docker/docker-compose.remote.dev.yaml new file mode 100644 index 0000000..e555ba4 --- /dev/null +++ b/Docker/docker-compose.remote.dev.yaml @@ -0,0 +1,40 @@ +services: + postgres: + image: postgres:16-alpine + container_name: royal-pop-postgres + restart: unless-stopped + environment: + POSTGRES_DB: royalpop + POSTGRES_USER: royalpop + POSTGRES_PASSWORD: royalpop_dev_password + ports: + - "5432:5432" + volumes: + - royal_pop_postgres_data:/var/lib/postgresql/data + + api: + image: registry.mangopig.tech/goko/royal-pop/api/dev:${TAG:-latest} + container_name: royal-pop-api + restart: unless-stopped + env_file: + - ../Env/.env.local + environment: + DATABASE_URL: postgres://royalpop:royalpop_dev_password@postgres:5432/royalpop?sslmode=disable + ports: + - "8081:8081" + depends_on: + - postgres + + store: + image: registry.mangopig.tech/goko/royal-pop/store/dev:${TAG:-latest} + container_name: royal-pop-store + restart: unless-stopped + env_file: + - ../Env/.env.local + environment: + API_BASE_URL: http://localhost:8081 + ports: + - "4321:4321" + +volumes: + royal_pop_postgres_data: diff --git a/Docker/docker-compose.remote.prod.yaml b/Docker/docker-compose.remote.prod.yaml new file mode 100644 index 0000000..6a30ff8 --- /dev/null +++ b/Docker/docker-compose.remote.prod.yaml @@ -0,0 +1,42 @@ +name: royal-pop-accessory + +services: + postgres: + image: postgres:16-alpine + container_name: royal-pop-postgres + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-royalpop} + POSTGRES_USER: ${POSTGRES_USER:-royalpop} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-royalpop_change_me} + volumes: + - royal_pop_postgres_data:/var/lib/postgresql/data + + api: + image: registry.mangopig.tech/goko/royal-pop/api/prod:${TAG:-latest} + container_name: royal-pop-api + restart: unless-stopped + env_file: + - ../Env/.env.production + environment: + DATABASE_URL: ${DATABASE_URL:-postgres://royalpop:${POSTGRES_PASSWORD:-royalpop_change_me}@postgres:5432/royalpop?sslmode=disable} + expose: + - "8081" + depends_on: + - postgres + + proxy: + image: registry.mangopig.tech/goko/royal-pop/proxy/prod:${TAG:-latest} + container_name: royal-pop-proxy + restart: unless-stopped + depends_on: + - api + ports: + - "80:80" + - "443:443" + volumes: + - ../Env/origin.crt:/etc/nginx/certs/origin.crt:ro + - ../Env/origin.key:/etc/nginx/certs/origin.key:ro + +volumes: + royal_pop_postgres_data: diff --git a/Env/.env.example b/Env/.env.example new file mode 100644 index 0000000..de85bff --- /dev/null +++ b/Env/.env.example @@ -0,0 +1,54 @@ +# Copy this file to one of: +# - Env/.env.local +# - Env/.env.production +# +# Notes: +# - Use real secrets only in the ignored local files above. +# - Local dev can rely on more app/compose defaults; production should set these explicitly. + +## App runtime +APP_NAME=royal-pop +GO_ENV=production +BACKEND_API_PORT=8081 +BACKEND_SHUTDOWN_TIMEOUT=10s + +## Public URLs / frontend build args +STOREFRONT_URL=https://example.com +API_BASE_URL=https://api.example.com +API_ALLOWED_ORIGINS=https://example.com,https://www.example.com +ALLOWED_HOSTS=example.com,www.example.com + +## Postgres / compose overrides +POSTGRES_DB=royalpop +POSTGRES_USER=royalpop +POSTGRES_PASSWORD=change_me +# Optional: override the compose-generated DATABASE_URL if needed. +# DATABASE_URL=postgres://royalpop:change_me@postgres:5432/royalpop?sslmode=disable + +## Client dashboard auth +CLIENT_DASHBOARD_USERNAME=change_me +CLIENT_DASHBOARD_PASSWORD=change_me +CLIENT_DASHBOARD_SESSION_SECRET=generate_a_random_secret_here +# Optional; defaults to 12h if omitted. +CLIENT_DASHBOARD_SESSION_TTL=12h + +## Email +RESEND_API_KEY=replace_with_resend_api_key +RESEND_ORDER_FROM=orders@example.com +RESEND_FORWARD=orders@example.com + +## Stripe +STRIPE_PUBLISHABLE_KEY=replace_with_stripe_publishable_key +STRIPE_SECRET_KEY=replace_with_stripe_secret_key +STRIPE_WEBHOOK_SECRET=replace_with_stripe_webhook_secret +STRIPE_PRICE_ID=replace_with_stripe_price_id +STRIPE_CURRENCY=gbp + +## Pricing +ROYAL_POP_UNIT_AMOUNT=4999 +# Optional compare-at/reference amount used in some pricing responses. +ROYAL_POP_RETAIL_AMOUNT=8999 + +## Remote registry compose / publish helpers +# Optional; defaults to latest when omitted. +# TAG=latest diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..97560f3 --- /dev/null +++ b/Justfile @@ -0,0 +1,8 @@ +set shell := ["bash", "-cu"] + +mod local "Commands/Local" +mod remote "Commands/Remote" + +# Show the full command tree. +help: + @just --list --list-submodules diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ddfc411 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 1. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 1. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 1. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 1. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 1. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 1. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 1. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 1. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 1. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 1. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 1. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 1. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 1. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 1. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 1. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 1. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and`show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/Proxy/Dockerfile b/Proxy/Dockerfile new file mode 100644 index 0000000..cbd2df1 --- /dev/null +++ b/Proxy/Dockerfile @@ -0,0 +1,32 @@ +# syntax=docker/dockerfile:1.7 + +FROM node:24.16.0-alpine AS build + +WORKDIR /app/Store + +RUN corepack enable && corepack prepare pnpm@10.24.0 --activate + +COPY Store/package.json Store/pnpm-lock.yaml Store/pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile + +ARG ALLOWED_HOSTS="" +ARG STRIPE_PUBLISHABLE_KEY="" +ARG STRIPE_PRICE_ID="" +ARG API_BASE_URL="" + +ENV ALLOWED_HOSTS=${ALLOWED_HOSTS} +ENV STRIPE_PUBLISHABLE_KEY=${STRIPE_PUBLISHABLE_KEY} +ENV STRIPE_PRICE_ID=${STRIPE_PRICE_ID} +ENV API_BASE_URL=${API_BASE_URL} + +COPY Store/ ./ + +RUN pnpm build + +FROM nginx:1.27-alpine AS runtime + +COPY Proxy/default.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/Store/dist /usr/share/nginx/html + +EXPOSE 80 +EXPOSE 443 diff --git a/Proxy/default.conf b/Proxy/default.conf new file mode 100644 index 0000000..583dd2a --- /dev/null +++ b/Proxy/default.conf @@ -0,0 +1,99 @@ +server { + listen 80; + listen [::]:80; + server_name royal-pop-accessory.com www.royal-pop-accessory.com; + + return 308 https://$host$request_uri; +} + +server { + listen 80; + listen [::]:80; + server_name api.royal-pop-accessory.com; + + return 308 https://$host$request_uri; +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name royal-pop-accessory.com www.royal-pop-accessory.com; + + root /usr/share/nginx/html; + index index.html; + + ssl_certificate /etc/nginx/certs/origin.crt; + ssl_certificate_key /etc/nginx/certs/origin.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + + gzip on; + gzip_types text/plain text/css application/json application/javascript application/xml+rss application/xml image/svg+xml; + + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + add_header X-Frame-Options SAMEORIGIN always; + + location = /buy { + return 308 $scheme://$host/buy/; + } + + location = /details { + return 308 $scheme://$host/details/; + } + + location = /checkout { + return 308 $scheme://$host/checkout/; + } + + location = /client { + return 308 $scheme://$host/client/; + } + + location = /client/orders { + return 308 $scheme://$host/client/orders/; + } + + location = /client/stock { + return 308 $scheme://$host/client/stock/; + } + + location = /thank-you { + return 308 $scheme://$host/thank-you/; + } + + location / { + try_files $uri $uri/ =404; + } +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name api.royal-pop-accessory.com; + + ssl_certificate /etc/nginx/certs/origin.crt; + ssl_certificate_key /etc/nginx/certs/origin.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + + gzip on; + gzip_types text/plain text/css application/json application/javascript application/xml+rss application/xml image/svg+xml; + + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + add_header X-Frame-Options SAMEORIGIN always; + + client_max_body_size 1m; + + location / { + proxy_pass http://api:8081; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/Proxy/docker-bake.hcl b/Proxy/docker-bake.hcl new file mode 100644 index 0000000..2927ed9 --- /dev/null +++ b/Proxy/docker-bake.hcl @@ -0,0 +1,59 @@ +variable "REGISTRY" { + default = "registry.mangopig.tech" +} + +variable "TAG" { + default = "latest" +} + +variable "ALLOWED_HOSTS" { + default = "" +} + +variable "STRIPE_PUBLISHABLE_KEY" { + default = "" +} + +variable "STRIPE_PRICE_ID" { + default = "" +} + +variable "API_BASE_URL" { + default = "" +} + +target "_proxy" { + context = "." + dockerfile = "Proxy/Dockerfile" + args = { + ALLOWED_HOSTS = ALLOWED_HOSTS + STRIPE_PUBLISHABLE_KEY = STRIPE_PUBLISHABLE_KEY + STRIPE_PRICE_ID = STRIPE_PRICE_ID + API_BASE_URL = API_BASE_URL + } +} + +target "prod" { + inherits = ["_proxy"] + target = "runtime" + tags = ["goko/royal-pop/proxy:local-prod"] +} + +target "prod-image" { + inherits = ["_proxy"] + target = "runtime" + tags = ["${REGISTRY}/goko/royal-pop/proxy/prod:${TAG}"] + output = ["type=registry"] +} + +group "local" { + targets = ["prod"] +} + +group "registry" { + targets = ["prod-image"] +} + +group "default" { + targets = ["prod"] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/Store/.dockerignore b/Store/.dockerignore new file mode 100644 index 0000000..a44058c --- /dev/null +++ b/Store/.dockerignore @@ -0,0 +1,3 @@ +.DS_Store +node_modules +dist \ No newline at end of file diff --git a/Store/Dockerfile b/Store/Dockerfile new file mode 100644 index 0000000..ab69e3a --- /dev/null +++ b/Store/Dockerfile @@ -0,0 +1,55 @@ +# Base image +FROM node:24.16.0-alpine AS base + +WORKDIR /app + +RUN corepack enable && corepack prepare pnpm@10.24.0 --activate + +COPY package*.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile + +# Development Build +FROM base AS development + +ARG ALLOWED_HOSTS="" +ARG STRIPE_PUBLISHABLE_KEY="" +ARG STRIPE_PRICE_ID="" +ARG API_BASE_URL="" + +COPY . . + +ENV HOST=0.0.0.0 +ENV PORT=4321 +ENV ALLOWED_HOSTS=${ALLOWED_HOSTS} +ENV STRIPE_PUBLISHABLE_KEY=${STRIPE_PUBLISHABLE_KEY} +ENV STRIPE_PRICE_ID=${STRIPE_PRICE_ID} +ENV API_BASE_URL=${API_BASE_URL} + +EXPOSE 4321 + +CMD ["pnpm", "dev", "--host", "0.0.0.0", "--port", "4321"] + +# Build stage for production assets +FROM base AS build + +ARG ALLOWED_HOSTS="" +ARG STRIPE_PUBLISHABLE_KEY="" +ARG STRIPE_PRICE_ID="" +ARG API_BASE_URL="" + +COPY . . + +ENV ALLOWED_HOSTS=${ALLOWED_HOSTS} +ENV STRIPE_PUBLISHABLE_KEY=${STRIPE_PUBLISHABLE_KEY} +ENV STRIPE_PRICE_ID=${STRIPE_PRICE_ID} +ENV API_BASE_URL=${API_BASE_URL} + +RUN pnpm build + + +# Production runtime for static assets +FROM nginx:1.27-alpine AS production + +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 diff --git a/Store/astro.config.mjs b/Store/astro.config.mjs new file mode 100644 index 0000000..d99f443 --- /dev/null +++ b/Store/astro.config.mjs @@ -0,0 +1,29 @@ +// @ts-check +import { defineConfig } from "astro/config"; + +const extraAllowedHosts = (process.env.ALLOWED_HOSTS ?? "") + .split(",") + .map((host) => host.trim()) + .filter(Boolean); + +// https://astro.build/config +export default defineConfig({ + output: "static", + site: "https://royal-pop-accessory.com", + // adapter: node({ + // mode: "standalone", + // }), + server: { + host: true, + allowedHosts: ["localhost", ...extraAllowedHosts], + }, + vite: { + css: { + preprocessorOptions: { + scss: { + additionalData: `@use "/src/styles/vars" as *; \n`, + }, + }, + }, + }, +}); diff --git a/Store/docker-bake.hcl b/Store/docker-bake.hcl new file mode 100644 index 0000000..407128f --- /dev/null +++ b/Store/docker-bake.hcl @@ -0,0 +1,59 @@ +variable "REGISTRY" { + default = "registry.mangopig.tech" +} + +variable "TAG" { + default = "latest" +} + +variable "ALLOWED_HOSTS" { + default = "" +} + +variable "STRIPE_PUBLISHABLE_KEY" { + default = "" +} + +variable "STRIPE_PRICE_ID" { + default = "" +} + +variable "API_BASE_URL" { + default = "" +} + +target "_app" { + context = "." + dockerfile = "Dockerfile" + args = { + ALLOWED_HOSTS = ALLOWED_HOSTS + STRIPE_PUBLISHABLE_KEY = STRIPE_PUBLISHABLE_KEY + STRIPE_PRICE_ID = STRIPE_PRICE_ID + API_BASE_URL = API_BASE_URL + } +} + +target "dev" { + inherits = ["_app"] + target = "development" + tags = ["goko/royal-pop/store:dev"] +} + +target "dev-image" { + inherits = ["_app"] + target = "development" + tags = ["${REGISTRY}/goko/royal-pop/store/dev:${TAG}"] + output = ["type=registry"] +} + +group "local" { + targets = ["dev"] +} + +group "registry" { + targets = ["dev-image"] +} + +group "default" { + targets = ["dev"] +} diff --git a/Store/package.json b/Store/package.json new file mode 100644 index 0000000..83a0104 --- /dev/null +++ b/Store/package.json @@ -0,0 +1,28 @@ +{ + "name": "royal-pop-store", + "type": "module", + "version": "0.0.1", + "engines": { + "node": ">=22.12.0" + }, + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview", + "astro": "astro" + }, + "dependencies": { + "@astrojs/node": "^10.1.1", + "astro": "^6.3.8", + "sass": "^1.100.0" + }, + "devDependencies": { + "@types/node": "^25.9.1", + "autoprefixer": "^10.5.0", + "cssnano": "^8.0.1", + "postcss-preset-env": "^11.3.0" + }, + "browserslist": [ + "defaults" + ] +} diff --git a/Store/pnpm-lock.yaml b/Store/pnpm-lock.yaml new file mode 100644 index 0000000..91c7bc5 --- /dev/null +++ b/Store/pnpm-lock.yaml @@ -0,0 +1,4954 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@astrojs/node': + specifier: ^10.1.1 + version: 10.1.1(astro@6.3.8(@types/node@25.9.1)(rollup@4.60.4)(sass@1.100.0)) + astro: + specifier: ^6.3.8 + version: 6.3.8(@types/node@25.9.1)(rollup@4.60.4)(sass@1.100.0) + sass: + specifier: ^1.100.0 + version: 1.100.0 + devDependencies: + '@types/node': + specifier: ^25.9.1 + version: 25.9.1 + autoprefixer: + specifier: ^10.5.0 + version: 10.5.0(postcss@8.5.15) + cssnano: + specifier: ^8.0.1 + version: 8.0.1(postcss@8.5.15) + postcss-preset-env: + specifier: ^11.3.0 + version: 11.3.0(postcss@8.5.15) + +packages: + + '@astrojs/compiler@4.0.0': + resolution: {integrity: sha512-eouss7G8ygdZqHuke033VMcVw5HTZUu+PXd/h06DGDUg/jt5btPYPqh66ENWw/mU78rBrf/oeC4oqoBwMtDMNA==} + + '@astrojs/internal-helpers@0.9.1': + resolution: {integrity: sha512-1pWuARqYom/TzuU3+0ZugsTrKlUydWKuULmDqSMTuonY+9IRDUEGKX/8PXQ1nBxRq3w85uGtd9q9SXfqEldMIQ==} + + '@astrojs/markdown-remark@7.1.2': + resolution: {integrity: sha512-caXZ4Dc2St2dW8luEg22GlP0gupLdztCTQE4EzZOxW1pqWXz9mbeJEuHUkgDYcKWW8tjIHkydYDhWLVoxJ327Q==} + + '@astrojs/node@10.1.1': + resolution: {integrity: sha512-kCRbxconkgPpY4vR0GS7exovWEiCbxXLarsp+JeKixyDNf+fKN6v7jXDL8KdQgrzjhy131Kvl+GGGX8jGd8adA==} + peerDependencies: + astro: ^6.3.0 + + '@astrojs/prism@4.0.2': + resolution: {integrity: sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==} + engines: {node: '>=22.12.0'} + + '@astrojs/telemetry@3.3.2': + resolution: {integrity: sha512-j8DNruA8ors99Al39RYZPJK4DC1bKkoNm93mAMuBhY9TCNC4R8n1q7ovFnJ5qhGh5Lsh7pa1gpQVpYpsJPeTHQ==} + engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@capsizecss/unpack@4.0.0': + resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==} + engines: {node: '>=18'} + + '@clack/core@1.3.1': + resolution: {integrity: sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.4.0': + resolution: {integrity: sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==} + engines: {node: '>= 20.12.0'} + + '@colordx/core@5.4.3': + resolution: {integrity: sha512-kIxYSfA5T8HXjav55UaaH/o/cKivF6jCCGIb8eqtcsfI46wsvlSiT8jMDyrl779qLec3c2c2oHBZo4oAhvbjrQ==} + + '@csstools/cascade-layer-name-parser@3.0.0': + resolution: {integrity: sha512-/3iksyevwRfSJx5yH0RkcrcYXwuhMQx3Juqf40t97PeEy2/Mz2TItZ/z/216qpe4GgOyFBP8MKIwVvytzHmfIQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.1': + resolution: {integrity: sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@csstools/media-query-list-parser@5.0.0': + resolution: {integrity: sha512-T9lXmZOfnam3eMERPsszjY5NK0jX8RmThmmm99FZ8b7z8yMaFZWKwLWGZuTwdO3ddRY5fy13GmmEYZXB4I98Eg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/postcss-alpha-function@2.0.5': + resolution: {integrity: sha512-i2lNJ6b4GdMoybHlpUM07TIk8KQRXTTe7Qf8LfctQhjDRTIgaodWTQqzWU4fpWO/nxBWNkSloDM22Lw/30NBcg==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-cascade-layers@6.0.0': + resolution: {integrity: sha512-WhsECqmrEZQGqaPlBA7JkmF/CJ2/+wetL4fkL9sOPccKd32PQ1qToFM6gqSI5rkpmYqubvbxjEJhyMTHYK0vZQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-color-function-display-p3-linear@2.0.4': + resolution: {integrity: sha512-xrGqSFj9pu6XbJYD4NNCxYK9WFbf0KMfXFaisnJezkIRDZCwefUB2azkU4Zr0dFmLtIb9LlshrSZ0be1/QVthQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-color-function@5.0.4': + resolution: {integrity: sha512-PhUu86ppxKcNHHqrJ43ZL1mYa2uHKGRoY0KPbZA9k8iOaanL3I+1zYqbgVumxj1UgNTDw5BE3BUQ1Dono6bD6g==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-color-mix-function@4.0.4': + resolution: {integrity: sha512-zYS78MHBuih9f9qtPFcSvVXMKg9q/lNPeFJUjyw7+/W1VHRjubvs5MlzuC363UUeahAhrOvYdo2ZZhmlxZbj6w==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-color-mix-variadic-function-arguments@2.0.4': + resolution: {integrity: sha512-qlrABMEFPUqbCxX0aOsHcxQZo/8XgMqnEtqqtVUbdizcuTUtJyLdHike7hkoemwDspMSEotdIfRlUY4jhZaD+A==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-container-rule-prelude-list@1.0.1': + resolution: {integrity: sha512-c5qlevVGKHU+zDbVoUGSZl1Mw7Vl1gVRKv6cdIYnaoyM+9Ou23Ian0H5Gr2ZF+lsDWovPK03hOSAbkw6HS8aTg==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-content-alt-text@3.0.1': + resolution: {integrity: sha512-mK5lCgzgV/ZC+LgnFy4rAQVMcXR6HsnX3D1+4Q5gshSQsst5TtcvHbxTdzKy1XTv09sNZHJX8CO4CEQF9zA4ug==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-contrast-color-function@3.0.4': + resolution: {integrity: sha512-EiTZzUICztGqEuYg8AVCUWH9vH2jDzO6RryxMja+PWluZHP6n3/iG6i1leTt5LiDQjDUQlCRbQtMNj7V7S+b4Q==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-exponential-functions@3.0.3': + resolution: {integrity: sha512-mB/NoeHLBHh0LZiVSrFdRDA/NxSfmg4tSN9117IJH9bdC2BzSTVgc82h3Gu/sdBXay6kDH2sA7fbkTigMiEi2A==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-font-format-keywords@5.0.0': + resolution: {integrity: sha512-M1EjCe/J3u8fFhOZgRci74cQhJ7R0UFBX6T+WqoEvjrr8hVfMiV+HTYrzxLY5OW8YllvXYr5Q5t5OvJbsUSeDg==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-font-width-property@1.0.0': + resolution: {integrity: sha512-AvmySApdijbjYQuXXh95tb7iVnqZBbJrv3oajO927ksE/mDmJBiszm+psW8orL2lRGR8j6ZU5Uv9/ou2Z5KRKA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-gamut-mapping@3.0.4': + resolution: {integrity: sha512-2dWGsxtxypKU9Ra862F2335W8xegRwl9ohQ6hk808PiQlEahSaFtt5fqsGmKDaSiaFUx+2X8GZxVo970Ajr2vQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-gradients-interpolation-method@6.0.4': + resolution: {integrity: sha512-sC/7dqVTtQTniLjPp/NagzeUn4sGinnMTicNBLDzirKq/GNXuJaApBOnvBmgNXjV6XPizfMhNRYCk5stn3q2nQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-hwb-function@5.0.4': + resolution: {integrity: sha512-cl0KPaaeYyAXNHO3pqK8adbpbAGmIU1cT1thyaEkmP8yvbJvmyztkpdGADGqziUUoh4dZQ0IhHxOxnKQ296T+A==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-ic-unit@5.0.1': + resolution: {integrity: sha512-jmsVLXPdMBTlaJAhiEijhIR3qL0j75MrlRfhJEs91DF1Wlt2kpJTDsbpXQpYFzn1nPFHZC/WEf+Mw0I/HXkHzQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-image-function@1.0.0': + resolution: {integrity: sha512-iuQztV6Cfeuc7NczazfickrzEhALOpxUS0yWgGkmRY1zZ0CKjBBFc/7WWSN9qupfpNAzHY7cPNcJCqUhtr+YMw==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-initial@3.0.0': + resolution: {integrity: sha512-UVUrFmrTQyLomVepnjWlbBg7GoscLmXLwYFyjbcEnmpeGW7wde6lNpx5eM3eVwZI2M+7hCE3ykYnAsEPLcLa+Q==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-is-pseudo-class@6.0.0': + resolution: {integrity: sha512-1Hdy/ykg9RDo8vU8RiM2o+RaXO39WpFPaIkHxlAEJFofle/lc33tdQMKhBk3jR/Fe+uZNLOs3HlowFafyFptVw==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-light-dark-function@3.0.1': + resolution: {integrity: sha512-tD2MMJmZ6XXCHgDythLHcXQDNi5z7KEEWPe7JeB3vPcw+YMuMabpW5ugRqndhIrui+vduhc0Md7f7yGPCmOErg==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-logical-float-and-clear@4.0.0': + resolution: {integrity: sha512-NGzdIRVj/VxOa/TjVdkHeyiJoDihONV0+uB0csUdgWbFFr8xndtfqK8iIGP9IKJzco+w0hvBF2SSk2sDSTAnOQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-logical-overflow@3.0.0': + resolution: {integrity: sha512-5cRg93QXVskM0MNepHpPcL0WLSf5Hncky0DrFDQY/4ozbH5lH7SX5ejayVpNTGSX7IpOvu7ykQDLOdMMGYzwpA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-logical-overscroll-behavior@3.0.0': + resolution: {integrity: sha512-82Jnl/5Wi5jb19nQE1XlBHrZcNL3PzOgcj268cDkfwf+xi10HBqufGo1Unwf5n8bbbEFhEKgyQW+vFsc9iY1jw==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-logical-resize@4.0.0': + resolution: {integrity: sha512-L0T3q0gei/tGetCGZU0c7VN77VTivRpz1YZRNxjXYmW+85PKeI6U9YnSvDqLU2vBT2uN4kLEzfgZ0ThIZpN18A==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-logical-viewport-units@4.0.0': + resolution: {integrity: sha512-TA3AqVN/1IH3dKRC2UUWvprvwyOs2IeD7FDZk5Hz20w4q33yIuSg0i0gjyTUkcn90g8A4n7QpyZ2AgBrnYPnnA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-media-minmax@3.0.3': + resolution: {integrity: sha512-ch1tNS+1QayiHTGsyc53zv3AzrSd0zigjbkfLxoeuzzJyn32+P3V7em3u5vLVnqLMzBbEZK//GI13EVTIPRdDA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-media-queries-aspect-ratio-number-values@4.0.0': + resolution: {integrity: sha512-FDdC3lbrj8Vr0SkGIcSLTcRB7ApG6nlJFxOxkEF2C5hIZC1jtgjISFSGn/WjFdVkn8Dqe+Vx9QXI3axS2w1XHw==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-mixins@1.0.0': + resolution: {integrity: sha512-rz6qjT2w9L3k65jGc2dX+3oGiSrYQ70EZPDrINSmSVoVys7lLBFH0tvEa8DW2sr9cbRVD/W+1sy8+7bfu0JUfg==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-nested-calc@5.0.0': + resolution: {integrity: sha512-aPSw8P60e/i9BEfugauhikBqgjiwXcw3I9o4vXs+hktl4NSTgZRI0QHimxk9mst8N01A2TKDBxOln3mssRxiHQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-normalize-display-values@5.0.1': + resolution: {integrity: sha512-FcbEmoxDEGYvm2W3rQzVzcuo66+dDJjzzVDs+QwRmZLHYofGmMGwIKPqzF86/YW+euMDa7sh1xjWDvz/fzByZQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-oklab-function@5.0.4': + resolution: {integrity: sha512-vIgrKe5ffW99it5SUIXOBczGLSiTaHBhU6afVr9KPwoZ4uq9H0E3Ehvi+xsUjmvnAyMTxOUSszNo04kEhbvYjQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-position-area-property@2.0.0': + resolution: {integrity: sha512-TeEfzsJGB23Syv7yCm8AHCD2XTFujdjr9YYu9ebH64vnfCEvY4BG319jXAYSlNlf3Yc9PNJ6WnkDkUF5XVgSKQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-progressive-custom-properties@5.1.0': + resolution: {integrity: sha512-lt/4yHy2GdKcGVpK4OGhBdSIq+z2PXynSusSRggn/T4y7uFurYAhdHqo/aYM+xI37vNb8rJlEKchqKKvVCXROQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-property-rule-prelude-list@2.0.0': + resolution: {integrity: sha512-qcMAkc9AhpzHgmQCD8hoJgGYifcOAxd1exXjjxilMM6euwRE619xDa4UsKBCv/v4g+sS63sd6c29LPM8s2ylSQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-random-function@3.0.3': + resolution: {integrity: sha512-0EScyKxscGonwpi30Hj9DEAr0X8D2eDhOqqayQXE91gIqGli9UT+deLYqoogZLOy5GT+ncqltMqztc/q+0UkhA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-relative-color-syntax@4.0.4': + resolution: {integrity: sha512-reFFKD9eS602We8621e5cAroKD7hH4104duLNBBhzwawGN7dhbnL1+c/DRHqwyq6eGK35HaKMMiifEZhAztlOA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-scope-pseudo-class@5.0.0': + resolution: {integrity: sha512-kBrBFJcAji3MSHS4qQIihPvJfJC5xCabXLbejqDMiQi+86HD4eMBiTayAo46Urg7tlEmZZQFymFiJt+GH6nvXw==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-sign-functions@2.0.3': + resolution: {integrity: sha512-2BCPwlpeQweTC/8S8oQFYhYD5kxYkiroLf3AUJV2kVoKkSZ+4WM4rSwySXlKrqXL8HfCryAwVrJg7B0jr/RnOw==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-stepped-value-functions@5.0.3': + resolution: {integrity: sha512-nXMFQBz5Pi2LLG02iqm2k+scrqwtqJT9ta/gN8S79oBZ23M0E7O3wDJ20//3z5Q6HU5e+K0n+SmmxN6iWtbm6w==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-syntax-descriptor-syntax-production@2.0.0': + resolution: {integrity: sha512-elYcbdiBXAkPqvojB9kIBRuHY6htUhjSITtFQ+XiXnt6SvZCbNGxQmaaw6uZ7SPHu/+i/XVjzIt09/1k3SIerQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-system-ui-font-family@2.0.0': + resolution: {integrity: sha512-FyGZCgchFImFyiHS2x3rD5trAqatf/x23veBLTIgbaqyFfna6RNBD+Qf8HRSjt6HGMXOLhAjxJ3OoZg0bbn7Qw==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-text-decoration-shorthand@5.0.3': + resolution: {integrity: sha512-62fjggvIM1YYfDJPcErMUDkEZB6CByG8neTJqexnZe1hRBgCjD4dnXDLoCSSurjs1LzjBq6irFDpDaOvDZfrlw==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-trigonometric-functions@5.0.3': + resolution: {integrity: sha512-p9LTvLj+DFpl5RHbG/X9QGwg7BoMOBsRBZqsUAKKVvCw7MRCsk1P1llTUR/MW5nyZ4IsjFGDtDwTTj1reJjxvg==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-unset-value@5.0.0': + resolution: {integrity: sha512-EoO54sS2KCIfesvHyFYAW99RtzwHdgaJzhl7cqKZSaMYKZv3fXSOehDjAQx8WZBKn1JrMd7xJJI1T1BxPF7/jA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@csstools/selector-resolve-nested@4.0.0': + resolution: {integrity: sha512-9vAPxmp+Dx3wQBIUwc1v7Mdisw1kbbaGqXUM8QLTgWg7SoPGYtXBsMXvsFs/0Bn5yoFhcktzxNZGNaUt0VjgjA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss-selector-parser: ^7.1.1 + + '@csstools/selector-specificity@6.0.0': + resolution: {integrity: sha512-4sSgl78OtOXEX/2d++8A83zHNTgwCJMaR24FvsYL7Uf/VS8HZk9PTwR51elTbGqMuwH3szLvvOXEaVnqn0Z3zA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss-selector-parser: ^7.1.1 + + '@csstools/utilities@3.0.0': + resolution: {integrity: sha512-etDqA/4jYvOGBM6yfKCOsEXfH96BKztZdgGmGqKi2xHnDe0ILIBraRspwgYatJH9JsCZ5HCGoCst8w18EKOAdg==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@oslojs/encoding@1.1.0': + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + + '@parcel/watcher-android-arm64@2.5.6': + resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.6': + resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.6': + resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.6': + resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.6': + resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm-musl@2.5.6': + resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm64-musl@2.5.6': + resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-x64-glibc@2.5.6': + resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-x64-musl@2.5.6': + resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@parcel/watcher-win32-arm64@2.5.6': + resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.6': + resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.6': + resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.6': + resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} + engines: {node: '>= 10.0.0'} + + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + cpu: [x64] + os: [win32] + + '@shikijs/core@4.1.0': + resolution: {integrity: sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ==} + engines: {node: '>=20'} + + '@shikijs/engine-javascript@4.1.0': + resolution: {integrity: sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ==} + engines: {node: '>=20'} + + '@shikijs/engine-oniguruma@4.1.0': + resolution: {integrity: sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg==} + engines: {node: '>=20'} + + '@shikijs/langs@4.1.0': + resolution: {integrity: sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg==} + engines: {node: '>=20'} + + '@shikijs/primitive@4.1.0': + resolution: {integrity: sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw==} + engines: {node: '>=20'} + + '@shikijs/themes@4.1.0': + resolution: {integrity: sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw==} + engines: {node: '>=20'} + + '@shikijs/types@4.1.0': + resolution: {integrity: sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA==} + engines: {node: '>=20'} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/nlcst@2.0.3': + resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} + + '@types/node@25.9.1': + resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@ungap/structured-clone@1.3.1': + resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-iterate@2.0.1: + resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==} + + astro@6.3.8: + resolution: {integrity: sha512-xH2UA8Z17IS+JaqSlSkBor7jO6gd7zXTLdmu06nKpfpDDJFbi/7KZEy3NDmWxmier+6XrCZ9Z4aitO8jhC9oiA==} + engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'} + hasBin: true + + autoprefixer@10.5.0: + resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + baseline-browser-mapping@2.10.32: + resolution: {integrity: sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==} + engines: {node: '>=6.0.0'} + hasBin: true + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-api@3.0.0: + resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} + + caniuse-lite@1.0.30001793: + resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + common-ancestor-path@2.0.0: + resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==} + engines: {node: '>= 18'} + + cookie-es@1.2.3: + resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + crossws@0.3.5: + resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + + css-blank-pseudo@8.0.1: + resolution: {integrity: sha512-C5B2e5hCM4llrQkUms+KnWEMVW8K1n2XvX9G7ppfMZJQ7KAS/4rNnkP1Cs+HhWriOz1mWWTMFD4j1J7s31Dgug==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + css-has-pseudo@8.0.0: + resolution: {integrity: sha512-Uz/bsHRbOeir/5Oeuz85tq/yLJLxX+3dpoRdjNTshs6jjqwUg8XaEZGDd0ci3fw7l53Srw0EkJ8mYan0eW5uGQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + css-prefers-color-scheme@11.0.0: + resolution: {integrity: sha512-fv0mgtwUhh2m9iio3Kxc2CkrogjIaRdMFaaqyzSFdii17JF4cfPyMNX72B15ZW2Nrr/NZUpxI4dec1VMHYJvdw==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + cssdb@8.9.0: + resolution: {integrity: sha512-J8jOU/hLjaXcO1LldOLraJSQpfLXRKof0I7mtbRyOy2AAXgqst0x9rlgi2qXeD6d0ou3ZLqcPAMqYVbpCbrxEw==} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + cssnano-preset-default@8.0.1: + resolution: {integrity: sha512-OTdKeYMlvQ8KBgyej5ysktnWJoeyo7rGrVnm+bdpIHGvxhbTGPsOkB+7T1EdTuX00dGlQQb2UEbSPB1OpMXULw==} + engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + peerDependencies: + postcss: ^8.5.14 + + cssnano-utils@6.0.0: + resolution: {integrity: sha512-ztS9W/+uaDn+bkYmDhs+GdMveHJ3CL8IPNHpRqDUQXv5GJOTQAJjV1XUOInr9esLXSabQV1pLRZlJpyUwEqDyQ==} + engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + peerDependencies: + postcss: ^8.5.14 + + cssnano@8.0.1: + resolution: {integrity: sha512-oSiOnPQNNYjusTUlYJiE6xvFQG4don3N0QavaoV1BxIsC1zjvxOwikXlR7lG1EVmZNDDaJkHbQx1VRB8kaoMHA==} + engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + peerDependencies: + postcss: ^8.5.14 + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devalue@5.8.1: + resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dset@3.1.4: + resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} + engines: {node: '>=4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.362: + resolution: {integrity: sha512-PUY2DrLvkjkUuWqq+KPL2iWshrJsZOcIojzRQ7eXFacc9dWga7MGMJAa15VbiejSZB1PAXaRLAiKgruHP8LB1w==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + flattie@1.1.1: + resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} + engines: {node: '>=8'} + + fontace@0.4.1: + resolution: {integrity: sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==} + + fontkitten@1.0.3: + resolution: {integrity: sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==} + engines: {node: '>=20'} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@5.0.0-beta.4: + resolution: {integrity: sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==} + engines: {node: '>=20.20.0'} + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + + h3@1.15.11: + resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + + hast-util-from-html@2.0.3: + resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} + + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + immutable@5.1.5: + resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-docker@4.0.0: + resolution: {integrity: sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==} + engines: {node: '>=20'} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + lru-cache@11.5.0: + resolution: {integrity: sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==} + engines: {node: 20 || >=22} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + mdast-util-definitions@6.0.0: + resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + neotraverse@0.6.18: + resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} + engines: {node: '>= 10'} + + nlcst-to-string@4.0.0: + resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-mock-http@1.0.4: + resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==} + + node-releases@2.0.46: + resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} + engines: {node: '>=18'} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} + + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + + p-limit@7.3.0: + resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==} + engines: {node: '>=20'} + + p-queue@9.3.0: + resolution: {integrity: sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==} + engines: {node: '>=20'} + + p-timeout@7.0.1: + resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} + engines: {node: '>=20'} + + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + parse-latin@7.0.0: + resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + piccolore@0.1.3: + resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss-attribute-case-insensitive@8.0.0: + resolution: {integrity: sha512-fovIPEV35c2JzVXdmP+sp2xirbBMt54J+upU8u6TSj410kUU5+axgEzvBBSAX8KCybze8CFCelzFAw/FfWg2TA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + postcss-calc@10.1.1: + resolution: {integrity: sha512-NYEsLHh8DgG/PRH2+G9BTuUdtf9ViS+vdoQ0YA5OQdGsfN4ztiwtDWNtBl9EKeqNMFnIu8IKZ0cLxEQ5r5KVMw==} + engines: {node: ^18.12 || ^20.9 || >=22.0} + peerDependencies: + postcss: ^8.4.38 + + postcss-clamp@4.1.0: + resolution: {integrity: sha512-ry4b1Llo/9zz+PKC+030KUnPITTJAHeOwjfAyyB60eT0AorGLdzp52s31OsPRHRf8NchkgFoG2y6fCfn1IV1Ow==} + engines: {node: '>=7.6.0'} + peerDependencies: + postcss: ^8.4.6 + + postcss-color-functional-notation@8.0.4: + resolution: {integrity: sha512-Zn3yPgBFakVXthmA2n1NUMY7gdhuFUB/DrUJ0Eug/d0rl9wahMQZykp4NVTJLGzQrDUwZ2rzjiTeW5udxFNG8A==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + postcss-color-hex-alpha@11.0.0: + resolution: {integrity: sha512-NCGa6vjIyrjosz9GqRxVKbONBklz5TeipYqTJp3IqbnBWlBq5e5EMtG6MaX4vqk9LzocPfMQkuRK9tfk+OQuKg==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + postcss-color-rebeccapurple@11.0.0: + resolution: {integrity: sha512-g9561mx7cbdqx7XeO/L+lJzVlzu7bICyXr72efBVKZGxIhvBBJf9fGXn3Cb6U4Bwh3LbzQO2e9NWBLVYdX5Eag==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + postcss-colormin@8.0.0: + resolution: {integrity: sha512-KKwMmsSgsmdYXqrjQeqL3tnuIFtctiR1GEMHdjNpDpz/TCRkkkok2mMcreK2zVV3l7POWOmAkR2xYHUpRUK1DA==} + engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + peerDependencies: + postcss: ^8.5.14 + + postcss-convert-values@8.0.0: + resolution: {integrity: sha512-Ohtj3rNZWawTRePv5NCHTy8VJSdJ/G/uKuxcxJreOMichuqcT6uEl2TAnopVeJCJ/c13jaSqg7m63yFLM5zBsA==} + engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + peerDependencies: + postcss: ^8.5.14 + + postcss-custom-media@12.0.1: + resolution: {integrity: sha512-66syE14+VeqkUf0rRX0bvbTCbNRJF132jD+ceo8th1dap2YJEAqpdh5uG98CE3IbgHT7m9XM0GIlOazNWqQdeA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + postcss-custom-properties@15.0.1: + resolution: {integrity: sha512-cuyq8sd8dLY0GLbelz1KB8IMIoDECo6RVXMeHeXY2Uw3Q05k/d1GVITdaKLsheqrHbnxlwxzSRZQQ5u+rNtbMg==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + postcss-custom-selectors@9.0.1: + resolution: {integrity: sha512-2XBELy4DmdVKimChfaZ2id9u9CSGYQhiJ53SvlfBvMTzLMW2VxuMb9rHsMSQw9kRq/zSbhT5x13EaK8JSmK8KQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + postcss-dir-pseudo-class@10.0.0: + resolution: {integrity: sha512-DmtIzULpyC8XaH4b5AaUgt4Jic4QmrECqidNCdR7u7naQFdnxX80YI06u238a+ZVRXwURDxVzy0s/UQnWmpVeg==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + postcss-discard-comments@8.0.0: + resolution: {integrity: sha512-zGpvVLj2sbagEp+BTVETvAfkZdGVA6rALNujDK/WTIjdf1/rQOxOG8BBzkI8UQgnw8SkL6xffAfbtGMHFypadw==} + engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + peerDependencies: + postcss: ^8.5.14 + + postcss-discard-duplicates@8.0.0: + resolution: {integrity: sha512-zjRyYmNGI3PTipKBBtCgExlmZXQn49KvKoaiNnR2g+iXxeNk7GY5Js2ULtZXPrCYeqjPagrzKIBNcBocvXCR7g==} + engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + peerDependencies: + postcss: ^8.5.14 + + postcss-discard-empty@8.0.0: + resolution: {integrity: sha512-kxPJg6EqahbBvm+l7hpYYCtpsv8dlz7Tv6wJXUXZaeuY0WGS61DxfGdZR4uVB/Cx+yi3iOHQVSqpSHKMFaBg6Q==} + engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + peerDependencies: + postcss: ^8.5.14 + + postcss-discard-overridden@8.0.0: + resolution: {integrity: sha512-sW2OWH3l9p0FmBSVr228uztFseqroZxwgD7SGF0Ks0dRPDttSo3P8FK5ZBLtWBH2A5+chpB0J2fB/T8heKHLBw==} + engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + peerDependencies: + postcss: ^8.5.14 + + postcss-double-position-gradients@7.0.1: + resolution: {integrity: sha512-M69I4EolEGwiYa0KmxKWg4zZp2DxhlNM0Bz12OvHCj930GXDVCvFhdWNGsRscz6BIijN6tFryzSFsy8kMLyD5Q==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + postcss-focus-visible@11.0.0: + resolution: {integrity: sha512-VG1a9kBKizUBWS66t5xyB4uLONBnvZLCmZXxT40FALu8EF0QgVZBYy5ApC0KhmpHsv+pvHMJHB3agKHwmocWjw==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + postcss-focus-within@10.0.0: + resolution: {integrity: sha512-dvql0fzUTG+gcJYp+KTbag5vAjuo94LDYZHkqDV1rnf5gPGer1v/SrmIZBdvKU8moep3HbcbujqGjzSb3DL53Q==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + postcss-font-variant@5.0.0: + resolution: {integrity: sha512-1fmkBaCALD72CK2a9i468mA/+tr9/1cBxRRMXOUaZqO43oWPR5imcyPjXwuv7PXbCid4ndlP5zWhidQVVa3hmA==} + peerDependencies: + postcss: ^8.1.0 + + postcss-gap-properties@7.0.0: + resolution: {integrity: sha512-PSDF2QoZMRUbsINvXObQgxx4HExRP85QTT8qS/YN9fBsCPWCqUuwqAD6E6PNp0BqL/jU1eyWUBORaOK/J/9LDA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + postcss-image-set-function@8.0.0: + resolution: {integrity: sha512-rEGNkOkNusf4+IuMmfEoIdLuVmvbExGbmG+MIsyV6jR5UaWSoyPcAYHV/PxzVDCmudyF+2Nh/o6Ub2saqUdnuA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + postcss-lab-function@8.0.4: + resolution: {integrity: sha512-dqcJSzVasdELD9xqJ1wfP95uzP57J6zFd80c7S3AWK127H9zwqR9Kbk5ZgyIfN2DiMStI7Vq8E7ablXNeTvpew==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + postcss-logical@9.0.0: + resolution: {integrity: sha512-A4LNd9dk3q/juEUA9Gd8ALhBO3TeOeYurnyHLlf2aAToD94VHR8c5Uv7KNmf8YVRhTxvWsyug4c5fKtARzyIRQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + postcss-merge-longhand@8.0.0: + resolution: {integrity: sha512-YDmAmQ8H+ljfomVpSXvr9NA0GP01fraQJqjWBYoMVGg6rOT+PJLwPyeVo2ekn4WB4ZVSH5ddtK3DTRxbz6CFzg==} + engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + peerDependencies: + postcss: ^8.5.14 + + postcss-merge-rules@8.0.0: + resolution: {integrity: sha512-bgstL5mpi41dDpnYGDUcI3M814NWkCMcIWpwDqEHXkHg3BT7b4XRAfNEuwJncZOVn/67kVKvWzhfv/7xyrp2uQ==} + engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + peerDependencies: + postcss: ^8.5.14 + + postcss-minify-font-values@8.0.0: + resolution: {integrity: sha512-EnOHQEnSt6oH5NrL1DMFAQuwB2IOimFXTCzc9bKfUeH1jREbqIF5MAK4gQJQOC4mPUwJt4sWifAmNZ1qLu6j3Q==} + engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + peerDependencies: + postcss: ^8.5.14 + + postcss-minify-gradients@8.0.0: + resolution: {integrity: sha512-43iAnYIGk0ZjNx5X/rkIcHi6dhmu/vEjY0kqfUfxPuJRO+V7jx8uKIdcnL0dpfNoC5J9TSh3EtzLWbq0gpqnWA==} + engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + peerDependencies: + postcss: ^8.5.14 + + postcss-minify-params@8.0.0: + resolution: {integrity: sha512-z7w4QO7G55l4vMUK1Lmx03GW7iyRLgf2V5Dz/7ioSPLnXRjeD+b7m0XfAXUGrbBYYrJ6bXPk+3LoX5u4JfAcSg==} + engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + peerDependencies: + postcss: ^8.5.14 + + postcss-minify-selectors@8.0.1: + resolution: {integrity: sha512-c31D46811kTkQDxV1KTTow79axX6gj/01AY5G7cGZg3s31KvAwP13jEFXGAzQbJ7NvOFV1pRqEia6nrAdHU7qg==} + engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + peerDependencies: + postcss: ^8.5.14 + + postcss-nesting@14.0.0: + resolution: {integrity: sha512-YGFOfVrjxYfeGTS5XctP1WCI5hu8Lr9SmntjfRC+iX5hCihEO+QZl9Ra+pkjqkgoVdDKvb2JccpElcowhZtzpw==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + postcss-normalize-charset@8.0.0: + resolution: {integrity: sha512-s88FUNDSUD8m0wBYvTQQcubVts6zhXwBU8zCD4vkRKiecd0v8cOjHVIF9r/i+5xzS/WG3f98qq4XsOM0JqvfLA==} + engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + peerDependencies: + postcss: ^8.5.14 + + postcss-normalize-display-values@8.0.0: + resolution: {integrity: sha512-gG2nBxD27fiw6Luinb1QYKdM/Co5GornRJgSD+JTwNH4PGKxImP0qyruDDav49aHUPLY3qrL3qN3LvybO7IzxQ==} + engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + peerDependencies: + postcss: ^8.5.14 + + postcss-normalize-positions@8.0.0: + resolution: {integrity: sha512-t/wGqpehS20Ke7kc4QAsWpH+AJjUdMK/V5qV2RhrXkj8hO/fT1t1MJ8NL7sedWYk7ZqC7eISEJQonW5j0tU1MQ==} + engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + peerDependencies: + postcss: ^8.5.14 + + postcss-normalize-repeat-style@8.0.0: + resolution: {integrity: sha512-3ebOmGdCYKrBYyGKc1xhj0unEnW7beZpVU7JohVeGl7mTxR+7T6egpaawTWAVsB0pEIhcsbJVOjPKCJSoRO6Zg==} + engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + peerDependencies: + postcss: ^8.5.14 + + postcss-normalize-string@8.0.0: + resolution: {integrity: sha512-TvWCGZ/e04Tv31uJvOUtbexkfgUnqmQ3M2P5DkAaVzvOj+BvTkG2QjpA5Y71SL1SPxJcj4M23fNh+RDVCmG8kA==} + engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + peerDependencies: + postcss: ^8.5.14 + + postcss-normalize-timing-functions@8.0.0: + resolution: {integrity: sha512-uEfaXst5Xgqxv7geYUuz6vs9mn88K2NPY2RoIzM3BMmSjsdTSeppV9x2qIgrxsisdbSqF6IVhzI2occcte3hTA==} + engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + peerDependencies: + postcss: ^8.5.14 + + postcss-normalize-unicode@8.0.0: + resolution: {integrity: sha512-+WYngZaChEeTHZmWhmKtnJ4gTzWdINEaFcgWBnu6WdVu8Ftim8OBTcw768DuCC/3Aax9bZ9WkwrLGHym2Lzf+A==} + engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + peerDependencies: + postcss: ^8.5.14 + + postcss-normalize-url@8.0.0: + resolution: {integrity: sha512-4Mz9hZHn/QIB+YtFqTXrDmE2193GYxGb3F8uMfLvMicaEXCCUlDIJ658gFFJbqEGl9FYzwPtRiuNgbwlO9kkBg==} + engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + peerDependencies: + postcss: ^8.5.14 + + postcss-normalize-whitespace@8.0.0: + resolution: {integrity: sha512-V1f8tYnwIP5tscOXQFTKK8Y5EJ+R2GMpFJ6FjzwoKoQnhbqQy3IeSrDjJJb8JjVos8ut6Osi80Zybpayv/XjIQ==} + engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + peerDependencies: + postcss: ^8.5.14 + + postcss-opacity-percentage@3.0.0: + resolution: {integrity: sha512-K6HGVzyxUxd/VgZdX04DCtdwWJ4NGLG212US4/LA1TLAbHgmAsTWVR86o+gGIbFtnTkfOpb9sCRBx8K7HO66qQ==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-ordered-values@8.0.0: + resolution: {integrity: sha512-Dg9+itb6lmD0bxqhQyHCtXAwYRh0wUrx6Mp4/BNXgkLoJmdYMmWi+V+Pypw79Q6iQhxA8KFMHqLBITQJV2gKMA==} + engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + peerDependencies: + postcss: ^8.5.14 + + postcss-overflow-shorthand@7.0.0: + resolution: {integrity: sha512-9SLpjoUdGRoRrzoOdX66HbUs0+uDwfIAiXsRa7piKGOqPd6F4ZlON9oaDSP5r1Qpgmzw5L9Ht0undIK6igJPMA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + postcss-page-break@3.0.4: + resolution: {integrity: sha512-1JGu8oCjVXLa9q9rFTo4MbeeA5FMe00/9C7lN4va606Rdb+HkxXtXsmEDrIraQ11fGz/WvKWa8gMuCKkrXpTsQ==} + peerDependencies: + postcss: ^8 + + postcss-place@11.0.0: + resolution: {integrity: sha512-fAifpyjQ+fuDRp2nmF95WbotqbpjdazebedahXdfBxy5sHembOLpBQ1cHveZD9ZmjK26tYM8tikeNaUlp/KfHA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + postcss-preset-env@11.3.0: + resolution: {integrity: sha512-PpijTuY+NT35vvk7us0pw9lJVrsZZWukjONZsza2Kq1Gag8nrUXRkgdKdxyyhZPJ6R43L3/nLpspUK99TmU9xg==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + postcss-pseudo-class-any-link@11.0.0: + resolution: {integrity: sha512-DNFZ4GMa3C3pU5dM+UCTG1CEeLtS1ZqV5DKSqCTJQMn1G5jnd/30fS8+A7H4o5bSD3MOcnx+VgI+xPE9Z5Wvig==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + postcss-reduce-initial@8.0.0: + resolution: {integrity: sha512-DChcE9d528AKrlpCTHjhsAiOsWCk4H9ApHPS1QqRT3praObWTiWyn6W1UddGpc46K9LQnHwUu4YwaPUukGtXVA==} + engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + peerDependencies: + postcss: ^8.5.14 + + postcss-reduce-transforms@8.0.0: + resolution: {integrity: sha512-cLZT0som7vvumQT9XQCnSKOSnRinNQZd1Hm+J723Ney13E8CIydDhw6JwzsjPtgnYThTqn9Q45906gz6wxaAsw==} + engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + peerDependencies: + postcss: ^8.5.14 + + postcss-replace-overflow-wrap@4.0.0: + resolution: {integrity: sha512-KmF7SBPphT4gPPcKZc7aDkweHiKEEO8cla/GjcBK+ckKxiZslIu3C4GCRW3DNfL0o7yW7kMQu9xlZ1kXRXLXtw==} + peerDependencies: + postcss: ^8.0.3 + + postcss-selector-not@9.0.0: + resolution: {integrity: sha512-xhAtTdHnVU2M/CrpYOPyRUvg3njhVlKmn2GNYXDaRJV9Ygx4d5OkSkc7NINzjUqnbDFtaKXlISOBeyMXU/zyFQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss: ^8.4 + + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + engines: {node: '>=4'} + + postcss-svgo@8.0.0: + resolution: {integrity: sha512-Q2fMSYEiNE1ioDc/3sxvI24NdgA/MJno2XLNpOxgv8aCcJbym8mZY10/lDY5+AWCIc3Aiqzy2Wcp9/zaIXBZgQ==} + engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + peerDependencies: + postcss: ^8.5.14 + + postcss-unique-selectors@8.0.0: + resolution: {integrity: sha512-iObuolUX+ITJfMU2QQFQdh31JgSjNLPNjVs6YGAqBHvOvAWXMMNget6donQl83aQaeS32i5XeKZURUW/WBxIUw==} + engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + peerDependencies: + postcss: ^8.5.14 + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + + radix3@1.1.2: + resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + rehype-parse@9.0.1: + resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==} + + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + + rehype-stringify@10.0.1: + resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==} + + rehype@13.0.2: + resolution: {integrity: sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-smartypants@3.0.2: + resolution: {integrity: sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==} + engines: {node: '>=16.0.0'} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + retext-latin@4.0.0: + resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==} + + retext-smartypants@6.2.0: + resolution: {integrity: sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==} + + retext-stringify@4.0.0: + resolution: {integrity: sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==} + + retext@9.0.0: + resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==} + + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + sass@1.100.0: + resolution: {integrity: sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ==} + engines: {node: '>=20.19.0'} + hasBin: true + + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + + semver@7.8.1: + resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + server-destroy@1.0.1: + resolution: {integrity: sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shiki@4.1.0: + resolution: {integrity: sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q==} + engines: {node: '>=20'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + smol-toml@1.6.1: + resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} + engines: {node: '>= 18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + stylehacks@8.0.0: + resolution: {integrity: sha512-sWyjaJvBqHoVKYPbQ8JRvrGSPaYWtWrJsU+fGVtwKB1GE1rRPu3rC7T6UCuXLoL00Dwb+tsHe2T904r8Vnsx8w==} + engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} + peerDependencies: + postcss: ^8.5.14 + + svgo@4.0.1: + resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==} + engines: {node: '>=16'} + hasBin: true + + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + + tinyclip@0.1.12: + resolution: {integrity: sha512-Ae3OVUqifDw0wBriIBS7yVaW44Dp6eSHQcyq4Igc7eN2TJH/2YsicswaW+J/OuMvhpDPOKEgpAZCjkb4hpoyeA==} + engines: {node: ^16.14.0 || >= 17.3.0} + + tinyexec@1.2.2: + resolution: {integrity: sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==} + engines: {node: '>=18'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + ultrahtml@1.6.0: + resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} + + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unifont@0.7.4: + resolution: {integrity: sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==} + + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-modify-children@4.0.0: + resolution: {integrity: sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-children@3.0.0: + resolution: {integrity: sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + unstorage@1.17.5: + resolution: {integrity: sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==} + peerDependencies: + '@azure/app-configuration': ^1.8.0 + '@azure/cosmos': ^4.2.0 + '@azure/data-tables': ^13.3.0 + '@azure/identity': ^4.6.0 + '@azure/keyvault-secrets': ^4.9.0 + '@azure/storage-blob': ^12.26.0 + '@capacitor/preferences': ^6 || ^7 || ^8 + '@deno/kv': '>=0.9.0' + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 + '@planetscale/database': ^1.19.0 + '@upstash/redis': ^1.34.3 + '@vercel/blob': '>=0.27.1' + '@vercel/functions': ^2.2.12 || ^3.0.0 + '@vercel/kv': ^1 || ^2 || ^3 + aws4fetch: ^1.0.20 + db0: '>=0.2.1' + idb-keyval: ^6.2.1 + ioredis: ^5.4.2 + uploadthing: ^7.4.4 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@deno/kv': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/blob': + optional: true + '@vercel/functions': + optional: true + '@vercel/kv': + optional: true + aws4fetch: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + uploadthing: + optional: true + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@7.3.3: + resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + + which-pm-runs@1.1.0: + resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} + engines: {node: '>=4'} + + xxhash-wasm@1.1.0: + resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} + + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@astrojs/compiler@4.0.0': {} + + '@astrojs/internal-helpers@0.9.1': + dependencies: + picomatch: 4.0.4 + + '@astrojs/markdown-remark@7.1.2': + dependencies: + '@astrojs/internal-helpers': 0.9.1 + '@astrojs/prism': 4.0.2 + github-slugger: 2.0.0 + hast-util-from-html: 2.0.3 + hast-util-to-text: 4.0.2 + js-yaml: 4.1.1 + mdast-util-definitions: 6.0.0 + rehype-raw: 7.0.0 + rehype-stringify: 10.0.1 + remark-gfm: 4.0.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + remark-smartypants: 3.0.2 + retext-smartypants: 6.2.0 + shiki: 4.1.0 + smol-toml: 1.6.1 + unified: 11.0.5 + unist-util-remove-position: 5.0.0 + unist-util-visit: 5.1.0 + unist-util-visit-parents: 6.0.2 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@astrojs/node@10.1.1(astro@6.3.8(@types/node@25.9.1)(rollup@4.60.4)(sass@1.100.0))': + dependencies: + '@astrojs/internal-helpers': 0.9.1 + astro: 6.3.8(@types/node@25.9.1)(rollup@4.60.4)(sass@1.100.0) + send: 1.2.1 + server-destroy: 1.0.1 + transitivePeerDependencies: + - supports-color + + '@astrojs/prism@4.0.2': + dependencies: + prismjs: 1.30.0 + + '@astrojs/telemetry@3.3.2': + dependencies: + ci-info: 4.4.0 + dset: 3.1.4 + is-docker: 4.0.0 + is-wsl: 3.1.1 + which-pm-runs: 1.1.0 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@capsizecss/unpack@4.0.0': + dependencies: + fontkitten: 1.0.3 + + '@clack/core@1.3.1': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.4.0': + dependencies: + '@clack/core': 1.3.1 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@colordx/core@5.4.3': {} + + '@csstools/cascade-layer-name-parser@3.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-tokenizer@4.0.0': {} + + '@csstools/media-query-list-parser@5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/postcss-alpha-function@2.0.5(postcss@8.5.15)': + dependencies: + '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.15) + '@csstools/utilities': 3.0.0(postcss@8.5.15) + postcss: 8.5.15 + + '@csstools/postcss-cascade-layers@6.0.0(postcss@8.5.15)': + dependencies: + '@csstools/selector-specificity': 6.0.0(postcss-selector-parser@7.1.1) + postcss: 8.5.15 + postcss-selector-parser: 7.1.1 + + '@csstools/postcss-color-function-display-p3-linear@2.0.4(postcss@8.5.15)': + dependencies: + '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.15) + '@csstools/utilities': 3.0.0(postcss@8.5.15) + postcss: 8.5.15 + + '@csstools/postcss-color-function@5.0.4(postcss@8.5.15)': + dependencies: + '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.15) + '@csstools/utilities': 3.0.0(postcss@8.5.15) + postcss: 8.5.15 + + '@csstools/postcss-color-mix-function@4.0.4(postcss@8.5.15)': + dependencies: + '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.15) + '@csstools/utilities': 3.0.0(postcss@8.5.15) + postcss: 8.5.15 + + '@csstools/postcss-color-mix-variadic-function-arguments@2.0.4(postcss@8.5.15)': + dependencies: + '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.15) + '@csstools/utilities': 3.0.0(postcss@8.5.15) + postcss: 8.5.15 + + '@csstools/postcss-container-rule-prelude-list@1.0.1(postcss@8.5.15)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + postcss: 8.5.15 + + '@csstools/postcss-content-alt-text@3.0.1(postcss@8.5.15)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.15) + '@csstools/utilities': 3.0.0(postcss@8.5.15) + postcss: 8.5.15 + + '@csstools/postcss-contrast-color-function@3.0.4(postcss@8.5.15)': + dependencies: + '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.15) + '@csstools/utilities': 3.0.0(postcss@8.5.15) + postcss: 8.5.15 + + '@csstools/postcss-exponential-functions@3.0.3(postcss@8.5.15)': + dependencies: + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + postcss: 8.5.15 + + '@csstools/postcss-font-format-keywords@5.0.0(postcss@8.5.15)': + dependencies: + '@csstools/utilities': 3.0.0(postcss@8.5.15) + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + '@csstools/postcss-font-width-property@1.0.0(postcss@8.5.15)': + dependencies: + '@csstools/utilities': 3.0.0(postcss@8.5.15) + postcss: 8.5.15 + + '@csstools/postcss-gamut-mapping@3.0.4(postcss@8.5.15)': + dependencies: + '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + postcss: 8.5.15 + + '@csstools/postcss-gradients-interpolation-method@6.0.4(postcss@8.5.15)': + dependencies: + '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.15) + '@csstools/utilities': 3.0.0(postcss@8.5.15) + postcss: 8.5.15 + + '@csstools/postcss-hwb-function@5.0.4(postcss@8.5.15)': + dependencies: + '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.15) + '@csstools/utilities': 3.0.0(postcss@8.5.15) + postcss: 8.5.15 + + '@csstools/postcss-ic-unit@5.0.1(postcss@8.5.15)': + dependencies: + '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.15) + '@csstools/utilities': 3.0.0(postcss@8.5.15) + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + '@csstools/postcss-image-function@1.0.0(postcss@8.5.15)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.15) + '@csstools/utilities': 3.0.0(postcss@8.5.15) + postcss: 8.5.15 + + '@csstools/postcss-initial@3.0.0(postcss@8.5.15)': + dependencies: + postcss: 8.5.15 + + '@csstools/postcss-is-pseudo-class@6.0.0(postcss@8.5.15)': + dependencies: + '@csstools/selector-specificity': 6.0.0(postcss-selector-parser@7.1.1) + postcss: 8.5.15 + postcss-selector-parser: 7.1.1 + + '@csstools/postcss-light-dark-function@3.0.1(postcss@8.5.15)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.15) + '@csstools/utilities': 3.0.0(postcss@8.5.15) + postcss: 8.5.15 + + '@csstools/postcss-logical-float-and-clear@4.0.0(postcss@8.5.15)': + dependencies: + postcss: 8.5.15 + + '@csstools/postcss-logical-overflow@3.0.0(postcss@8.5.15)': + dependencies: + postcss: 8.5.15 + + '@csstools/postcss-logical-overscroll-behavior@3.0.0(postcss@8.5.15)': + dependencies: + postcss: 8.5.15 + + '@csstools/postcss-logical-resize@4.0.0(postcss@8.5.15)': + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + '@csstools/postcss-logical-viewport-units@4.0.0(postcss@8.5.15)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + '@csstools/utilities': 3.0.0(postcss@8.5.15) + postcss: 8.5.15 + + '@csstools/postcss-media-minmax@3.0.3(postcss@8.5.15)': + dependencies: + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/media-query-list-parser': 5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + postcss: 8.5.15 + + '@csstools/postcss-media-queries-aspect-ratio-number-values@4.0.0(postcss@8.5.15)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/media-query-list-parser': 5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + postcss: 8.5.15 + + '@csstools/postcss-mixins@1.0.0(postcss@8.5.15)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + postcss: 8.5.15 + + '@csstools/postcss-nested-calc@5.0.0(postcss@8.5.15)': + dependencies: + '@csstools/utilities': 3.0.0(postcss@8.5.15) + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + '@csstools/postcss-normalize-display-values@5.0.1(postcss@8.5.15)': + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + '@csstools/postcss-oklab-function@5.0.4(postcss@8.5.15)': + dependencies: + '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.15) + '@csstools/utilities': 3.0.0(postcss@8.5.15) + postcss: 8.5.15 + + '@csstools/postcss-position-area-property@2.0.0(postcss@8.5.15)': + dependencies: + postcss: 8.5.15 + + '@csstools/postcss-progressive-custom-properties@5.1.0(postcss@8.5.15)': + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + '@csstools/postcss-property-rule-prelude-list@2.0.0(postcss@8.5.15)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + postcss: 8.5.15 + + '@csstools/postcss-random-function@3.0.3(postcss@8.5.15)': + dependencies: + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + postcss: 8.5.15 + + '@csstools/postcss-relative-color-syntax@4.0.4(postcss@8.5.15)': + dependencies: + '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.15) + '@csstools/utilities': 3.0.0(postcss@8.5.15) + postcss: 8.5.15 + + '@csstools/postcss-scope-pseudo-class@5.0.0(postcss@8.5.15)': + dependencies: + postcss: 8.5.15 + postcss-selector-parser: 7.1.1 + + '@csstools/postcss-sign-functions@2.0.3(postcss@8.5.15)': + dependencies: + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + postcss: 8.5.15 + + '@csstools/postcss-stepped-value-functions@5.0.3(postcss@8.5.15)': + dependencies: + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + postcss: 8.5.15 + + '@csstools/postcss-syntax-descriptor-syntax-production@2.0.0(postcss@8.5.15)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + postcss: 8.5.15 + + '@csstools/postcss-system-ui-font-family@2.0.0(postcss@8.5.15)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + postcss: 8.5.15 + + '@csstools/postcss-text-decoration-shorthand@5.0.3(postcss@8.5.15)': + dependencies: + '@csstools/color-helpers': 6.0.2 + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + '@csstools/postcss-trigonometric-functions@5.0.3(postcss@8.5.15)': + dependencies: + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + postcss: 8.5.15 + + '@csstools/postcss-unset-value@5.0.0(postcss@8.5.15)': + dependencies: + postcss: 8.5.15 + + '@csstools/selector-resolve-nested@4.0.0(postcss-selector-parser@7.1.1)': + dependencies: + postcss-selector-parser: 7.1.1 + + '@csstools/selector-specificity@6.0.0(postcss-selector-parser@7.1.1)': + dependencies: + postcss-selector-parser: 7.1.1 + + '@csstools/utilities@3.0.0(postcss@8.5.15)': + dependencies: + postcss: 8.5.15 + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.10.0 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@oslojs/encoding@1.1.0': {} + + '@parcel/watcher-android-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-x64@2.5.6': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.6': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.6': + optional: true + + '@parcel/watcher-win32-arm64@2.5.6': + optional: true + + '@parcel/watcher-win32-ia32@2.5.6': + optional: true + + '@parcel/watcher-win32-x64@2.5.6': + optional: true + + '@parcel/watcher@2.5.6': + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.1.1 + picomatch: 4.0.4 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.6 + '@parcel/watcher-darwin-arm64': 2.5.6 + '@parcel/watcher-darwin-x64': 2.5.6 + '@parcel/watcher-freebsd-x64': 2.5.6 + '@parcel/watcher-linux-arm-glibc': 2.5.6 + '@parcel/watcher-linux-arm-musl': 2.5.6 + '@parcel/watcher-linux-arm64-glibc': 2.5.6 + '@parcel/watcher-linux-arm64-musl': 2.5.6 + '@parcel/watcher-linux-x64-glibc': 2.5.6 + '@parcel/watcher-linux-x64-musl': 2.5.6 + '@parcel/watcher-win32-arm64': 2.5.6 + '@parcel/watcher-win32-ia32': 2.5.6 + '@parcel/watcher-win32-x64': 2.5.6 + optional: true + + '@rollup/pluginutils@5.3.0(rollup@4.60.4)': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.4 + optionalDependencies: + rollup: 4.60.4 + + '@rollup/rollup-android-arm-eabi@4.60.4': + optional: true + + '@rollup/rollup-android-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-x64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.4': + optional: true + + '@shikijs/core@4.1.0': + dependencies: + '@shikijs/primitive': 4.1.0 + '@shikijs/types': 4.1.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@4.1.0': + dependencies: + '@shikijs/types': 4.1.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + + '@shikijs/engine-oniguruma@4.1.0': + dependencies: + '@shikijs/types': 4.1.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@4.1.0': + dependencies: + '@shikijs/types': 4.1.0 + + '@shikijs/primitive@4.1.0': + dependencies: + '@shikijs/types': 4.1.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/themes@4.1.0': + dependencies: + '@shikijs/types': 4.1.0 + + '@shikijs/types@4.1.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/estree@1.0.8': {} + + '@types/estree@1.0.9': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/ms@2.1.0': {} + + '@types/nlcst@2.0.3': + dependencies: + '@types/unist': 3.0.3 + + '@types/node@25.9.1': + dependencies: + undici-types: 7.24.6 + + '@types/unist@3.0.3': {} + + '@ungap/structured-clone@1.3.1': {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + argparse@2.0.1: {} + + aria-query@5.3.2: {} + + array-iterate@2.0.1: {} + + astro@6.3.8(@types/node@25.9.1)(rollup@4.60.4)(sass@1.100.0): + dependencies: + '@astrojs/compiler': 4.0.0 + '@astrojs/internal-helpers': 0.9.1 + '@astrojs/markdown-remark': 7.1.2 + '@astrojs/telemetry': 3.3.2 + '@capsizecss/unpack': 4.0.0 + '@clack/prompts': 1.4.0 + '@oslojs/encoding': 1.1.0 + '@rollup/pluginutils': 5.3.0(rollup@4.60.4) + aria-query: 5.3.2 + axobject-query: 4.1.0 + ci-info: 4.4.0 + clsx: 2.1.1 + common-ancestor-path: 2.0.0 + cookie: 1.1.1 + devalue: 5.8.1 + diff: 8.0.4 + dset: 3.1.4 + es-module-lexer: 2.1.0 + esbuild: 0.27.7 + flattie: 1.1.1 + fontace: 0.4.1 + get-tsconfig: 5.0.0-beta.4 + github-slugger: 2.0.0 + html-escaper: 3.0.3 + http-cache-semantics: 4.2.0 + js-yaml: 4.1.1 + jsonc-parser: 3.3.1 + magic-string: 0.30.21 + magicast: 0.5.3 + mrmime: 2.0.1 + neotraverse: 0.6.18 + obug: 2.1.1 + p-limit: 7.3.0 + p-queue: 9.3.0 + package-manager-detector: 1.6.0 + piccolore: 0.1.3 + picomatch: 4.0.4 + rehype: 13.0.2 + semver: 7.8.1 + shiki: 4.1.0 + smol-toml: 1.6.1 + svgo: 4.0.1 + tinyclip: 0.1.12 + tinyexec: 1.2.2 + tinyglobby: 0.2.16 + ultrahtml: 1.6.0 + unifont: 0.7.4 + unist-util-visit: 5.1.0 + unstorage: 1.17.5 + vfile: 6.0.3 + vite: 7.3.3(@types/node@25.9.1)(sass@1.100.0) + vitefu: 1.1.3(vite@7.3.3(@types/node@25.9.1)(sass@1.100.0)) + xxhash-wasm: 1.1.0 + yargs-parser: 22.0.0 + zod: 4.4.3 + optionalDependencies: + sharp: 0.34.5 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - idb-keyval + - ioredis + - jiti + - less + - lightningcss + - rollup + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - uploadthing + - yaml + + autoprefixer@10.5.0(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + caniuse-lite: 1.0.30001793 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + axobject-query@4.1.0: {} + + bail@2.0.2: {} + + baseline-browser-mapping@2.10.32: {} + + boolbase@1.0.0: {} + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.32 + caniuse-lite: 1.0.30001793 + electron-to-chromium: 1.5.362 + node-releases: 2.0.46 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + caniuse-api@3.0.0: + dependencies: + browserslist: 4.28.2 + caniuse-lite: 1.0.30001793 + lodash.memoize: 4.1.2 + lodash.uniq: 4.5.0 + + caniuse-lite@1.0.30001793: {} + + ccount@2.0.1: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + ci-info@4.4.0: {} + + clsx@2.1.1: {} + + comma-separated-tokens@2.0.3: {} + + commander@11.1.0: {} + + common-ancestor-path@2.0.0: {} + + cookie-es@1.2.3: {} + + cookie@1.1.1: {} + + crossws@0.3.5: + dependencies: + uncrypto: 0.1.3 + + css-blank-pseudo@8.0.1(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-selector-parser: 7.1.1 + + css-has-pseudo@8.0.0(postcss@8.5.15): + dependencies: + '@csstools/selector-specificity': 6.0.0(postcss-selector-parser@7.1.1) + postcss: 8.5.15 + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + + css-prefers-color-scheme@11.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.1 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css-what@6.2.2: {} + + cssdb@8.9.0: {} + + cssesc@3.0.0: {} + + cssnano-preset-default@8.0.1(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + cssnano-utils: 6.0.0(postcss@8.5.15) + postcss: 8.5.15 + postcss-calc: 10.1.1(postcss@8.5.15) + postcss-colormin: 8.0.0(postcss@8.5.15) + postcss-convert-values: 8.0.0(postcss@8.5.15) + postcss-discard-comments: 8.0.0(postcss@8.5.15) + postcss-discard-duplicates: 8.0.0(postcss@8.5.15) + postcss-discard-empty: 8.0.0(postcss@8.5.15) + postcss-discard-overridden: 8.0.0(postcss@8.5.15) + postcss-merge-longhand: 8.0.0(postcss@8.5.15) + postcss-merge-rules: 8.0.0(postcss@8.5.15) + postcss-minify-font-values: 8.0.0(postcss@8.5.15) + postcss-minify-gradients: 8.0.0(postcss@8.5.15) + postcss-minify-params: 8.0.0(postcss@8.5.15) + postcss-minify-selectors: 8.0.1(postcss@8.5.15) + postcss-normalize-charset: 8.0.0(postcss@8.5.15) + postcss-normalize-display-values: 8.0.0(postcss@8.5.15) + postcss-normalize-positions: 8.0.0(postcss@8.5.15) + postcss-normalize-repeat-style: 8.0.0(postcss@8.5.15) + postcss-normalize-string: 8.0.0(postcss@8.5.15) + postcss-normalize-timing-functions: 8.0.0(postcss@8.5.15) + postcss-normalize-unicode: 8.0.0(postcss@8.5.15) + postcss-normalize-url: 8.0.0(postcss@8.5.15) + postcss-normalize-whitespace: 8.0.0(postcss@8.5.15) + postcss-ordered-values: 8.0.0(postcss@8.5.15) + postcss-reduce-initial: 8.0.0(postcss@8.5.15) + postcss-reduce-transforms: 8.0.0(postcss@8.5.15) + postcss-svgo: 8.0.0(postcss@8.5.15) + postcss-unique-selectors: 8.0.0(postcss@8.5.15) + + cssnano-utils@6.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + + cssnano@8.0.1(postcss@8.5.15): + dependencies: + cssnano-preset-default: 8.0.1(postcss@8.5.15) + lilconfig: 3.1.3 + postcss: 8.5.15 + + csso@5.0.5: + dependencies: + css-tree: 2.2.1 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + defu@6.1.7: {} + + depd@2.0.0: {} + + dequal@2.0.3: {} + + destr@2.0.5: {} + + detect-libc@2.1.2: + optional: true + + devalue@5.8.1: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + diff@8.0.4: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dset@3.1.4: {} + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.362: {} + + encodeurl@2.0.0: {} + + entities@4.5.0: {} + + entities@6.0.1: {} + + es-module-lexer@2.1.0: {} + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@5.0.0: {} + + estree-walker@2.0.2: {} + + etag@1.8.1: {} + + eventemitter3@5.0.4: {} + + extend@3.0.2: {} + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + flattie@1.1.1: {} + + fontace@0.4.1: + dependencies: + fontkitten: 1.0.3 + + fontkitten@1.0.3: + dependencies: + tiny-inflate: 1.0.3 + + fraction.js@5.3.4: {} + + fresh@2.0.0: {} + + fsevents@2.3.3: + optional: true + + get-tsconfig@5.0.0-beta.4: + dependencies: + resolve-pkg-maps: 1.0.0 + + github-slugger@2.0.0: {} + + h3@1.15.11: + dependencies: + cookie-es: 1.2.3 + crossws: 0.3.5 + defu: 6.1.7 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + node-mock-http: 1.0.4 + radix3: 1.1.2 + ufo: 1.6.4 + uncrypto: 0.1.3 + + hast-util-from-html@2.0.3: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + hast-util-from-parse5: 8.0.3 + parse5: 7.3.0 + vfile: 6.0.3 + vfile-message: 4.0.3 + + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.1.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.3.1 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.1 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + parse5: 7.3.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-to-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + + html-escaper@3.0.3: {} + + html-void-elements@3.0.0: {} + + http-cache-semantics@4.2.0: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + immutable@5.1.5: {} + + inherits@2.0.4: {} + + iron-webcrypto@1.2.1: {} + + is-docker@3.0.0: {} + + is-docker@4.0.0: {} + + is-extglob@2.1.1: + optional: true + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + optional: true + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-plain-obj@4.1.0: {} + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsonc-parser@3.3.1: {} + + lilconfig@3.1.3: {} + + lodash.memoize@4.1.2: {} + + lodash.uniq@4.5.0: {} + + longest-streak@3.1.0: {} + + lru-cache@11.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + markdown-table@3.0.4: {} + + mdast-util-definitions@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.1 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mdn-data@2.0.28: {} + + mdn-data@2.27.1: {} + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + neotraverse@0.6.18: {} + + nlcst-to-string@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + + node-addon-api@7.1.1: + optional: true + + node-fetch-native@1.6.7: {} + + node-mock-http@1.0.4: {} + + node-releases@2.0.46: {} + + normalize-path@3.0.0: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + obug@2.1.1: {} + + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.4 + + ohash@2.0.11: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + oniguruma-parser@0.12.2: {} + + oniguruma-to-es@4.3.6: + dependencies: + oniguruma-parser: 0.12.2 + regex: 6.1.0 + regex-recursion: 6.0.2 + + p-limit@7.3.0: + dependencies: + yocto-queue: 1.2.2 + + p-queue@9.3.0: + dependencies: + eventemitter3: 5.0.4 + p-timeout: 7.0.1 + + p-timeout@7.0.1: {} + + package-manager-detector@1.6.0: {} + + parse-latin@7.0.0: + dependencies: + '@types/nlcst': 2.0.3 + '@types/unist': 3.0.3 + nlcst-to-string: 4.0.0 + unist-util-modify-children: 4.0.0 + unist-util-visit-children: 3.0.0 + vfile: 6.0.3 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + piccolore@0.1.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + postcss-attribute-case-insensitive@8.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-selector-parser: 7.1.1 + + postcss-calc@10.1.1(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + + postcss-clamp@4.1.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + postcss-color-functional-notation@8.0.4(postcss@8.5.15): + dependencies: + '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.15) + '@csstools/utilities': 3.0.0(postcss@8.5.15) + postcss: 8.5.15 + + postcss-color-hex-alpha@11.0.0(postcss@8.5.15): + dependencies: + '@csstools/utilities': 3.0.0(postcss@8.5.15) + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + postcss-color-rebeccapurple@11.0.0(postcss@8.5.15): + dependencies: + '@csstools/utilities': 3.0.0(postcss@8.5.15) + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + postcss-colormin@8.0.0(postcss@8.5.15): + dependencies: + '@colordx/core': 5.4.3 + browserslist: 4.28.2 + caniuse-api: 3.0.0 + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + postcss-convert-values@8.0.0(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + postcss-custom-media@12.0.1(postcss@8.5.15): + dependencies: + '@csstools/cascade-layer-name-parser': 3.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/media-query-list-parser': 5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + postcss: 8.5.15 + + postcss-custom-properties@15.0.1(postcss@8.5.15): + dependencies: + '@csstools/cascade-layer-name-parser': 3.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/utilities': 3.0.0(postcss@8.5.15) + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + postcss-custom-selectors@9.0.1(postcss@8.5.15): + dependencies: + '@csstools/cascade-layer-name-parser': 3.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + postcss: 8.5.15 + postcss-selector-parser: 7.1.1 + + postcss-dir-pseudo-class@10.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-selector-parser: 7.1.1 + + postcss-discard-comments@8.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-selector-parser: 7.1.1 + + postcss-discard-duplicates@8.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + + postcss-discard-empty@8.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + + postcss-discard-overridden@8.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + + postcss-double-position-gradients@7.0.1(postcss@8.5.15): + dependencies: + '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.15) + '@csstools/utilities': 3.0.0(postcss@8.5.15) + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + postcss-focus-visible@11.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-selector-parser: 7.1.1 + + postcss-focus-within@10.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-selector-parser: 7.1.1 + + postcss-font-variant@5.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + + postcss-gap-properties@7.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + + postcss-image-set-function@8.0.0(postcss@8.5.15): + dependencies: + '@csstools/utilities': 3.0.0(postcss@8.5.15) + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + postcss-lab-function@8.0.4(postcss@8.5.15): + dependencies: + '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.15) + '@csstools/utilities': 3.0.0(postcss@8.5.15) + postcss: 8.5.15 + + postcss-logical@9.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + postcss-merge-longhand@8.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + stylehacks: 8.0.0(postcss@8.5.15) + + postcss-merge-rules@8.0.0(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + caniuse-api: 3.0.0 + cssnano-utils: 6.0.0(postcss@8.5.15) + postcss: 8.5.15 + postcss-selector-parser: 7.1.1 + + postcss-minify-font-values@8.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + postcss-minify-gradients@8.0.0(postcss@8.5.15): + dependencies: + '@colordx/core': 5.4.3 + cssnano-utils: 6.0.0(postcss@8.5.15) + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + postcss-minify-params@8.0.0(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + cssnano-utils: 6.0.0(postcss@8.5.15) + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + postcss-minify-selectors@8.0.1(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + caniuse-api: 3.0.0 + cssesc: 3.0.0 + postcss: 8.5.15 + postcss-selector-parser: 7.1.1 + + postcss-nesting@14.0.0(postcss@8.5.15): + dependencies: + '@csstools/selector-resolve-nested': 4.0.0(postcss-selector-parser@7.1.1) + '@csstools/selector-specificity': 6.0.0(postcss-selector-parser@7.1.1) + postcss: 8.5.15 + postcss-selector-parser: 7.1.1 + + postcss-normalize-charset@8.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + + postcss-normalize-display-values@8.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + postcss-normalize-positions@8.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + postcss-normalize-repeat-style@8.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + postcss-normalize-string@8.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + postcss-normalize-timing-functions@8.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + postcss-normalize-unicode@8.0.0(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + postcss-normalize-url@8.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + postcss-normalize-whitespace@8.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + postcss-opacity-percentage@3.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + + postcss-ordered-values@8.0.0(postcss@8.5.15): + dependencies: + cssnano-utils: 6.0.0(postcss@8.5.15) + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + postcss-overflow-shorthand@7.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + postcss-page-break@3.0.4(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + + postcss-place@11.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + postcss-preset-env@11.3.0(postcss@8.5.15): + dependencies: + '@csstools/postcss-alpha-function': 2.0.5(postcss@8.5.15) + '@csstools/postcss-cascade-layers': 6.0.0(postcss@8.5.15) + '@csstools/postcss-color-function': 5.0.4(postcss@8.5.15) + '@csstools/postcss-color-function-display-p3-linear': 2.0.4(postcss@8.5.15) + '@csstools/postcss-color-mix-function': 4.0.4(postcss@8.5.15) + '@csstools/postcss-color-mix-variadic-function-arguments': 2.0.4(postcss@8.5.15) + '@csstools/postcss-container-rule-prelude-list': 1.0.1(postcss@8.5.15) + '@csstools/postcss-content-alt-text': 3.0.1(postcss@8.5.15) + '@csstools/postcss-contrast-color-function': 3.0.4(postcss@8.5.15) + '@csstools/postcss-exponential-functions': 3.0.3(postcss@8.5.15) + '@csstools/postcss-font-format-keywords': 5.0.0(postcss@8.5.15) + '@csstools/postcss-font-width-property': 1.0.0(postcss@8.5.15) + '@csstools/postcss-gamut-mapping': 3.0.4(postcss@8.5.15) + '@csstools/postcss-gradients-interpolation-method': 6.0.4(postcss@8.5.15) + '@csstools/postcss-hwb-function': 5.0.4(postcss@8.5.15) + '@csstools/postcss-ic-unit': 5.0.1(postcss@8.5.15) + '@csstools/postcss-image-function': 1.0.0(postcss@8.5.15) + '@csstools/postcss-initial': 3.0.0(postcss@8.5.15) + '@csstools/postcss-is-pseudo-class': 6.0.0(postcss@8.5.15) + '@csstools/postcss-light-dark-function': 3.0.1(postcss@8.5.15) + '@csstools/postcss-logical-float-and-clear': 4.0.0(postcss@8.5.15) + '@csstools/postcss-logical-overflow': 3.0.0(postcss@8.5.15) + '@csstools/postcss-logical-overscroll-behavior': 3.0.0(postcss@8.5.15) + '@csstools/postcss-logical-resize': 4.0.0(postcss@8.5.15) + '@csstools/postcss-logical-viewport-units': 4.0.0(postcss@8.5.15) + '@csstools/postcss-media-minmax': 3.0.3(postcss@8.5.15) + '@csstools/postcss-media-queries-aspect-ratio-number-values': 4.0.0(postcss@8.5.15) + '@csstools/postcss-mixins': 1.0.0(postcss@8.5.15) + '@csstools/postcss-nested-calc': 5.0.0(postcss@8.5.15) + '@csstools/postcss-normalize-display-values': 5.0.1(postcss@8.5.15) + '@csstools/postcss-oklab-function': 5.0.4(postcss@8.5.15) + '@csstools/postcss-position-area-property': 2.0.0(postcss@8.5.15) + '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.15) + '@csstools/postcss-property-rule-prelude-list': 2.0.0(postcss@8.5.15) + '@csstools/postcss-random-function': 3.0.3(postcss@8.5.15) + '@csstools/postcss-relative-color-syntax': 4.0.4(postcss@8.5.15) + '@csstools/postcss-scope-pseudo-class': 5.0.0(postcss@8.5.15) + '@csstools/postcss-sign-functions': 2.0.3(postcss@8.5.15) + '@csstools/postcss-stepped-value-functions': 5.0.3(postcss@8.5.15) + '@csstools/postcss-syntax-descriptor-syntax-production': 2.0.0(postcss@8.5.15) + '@csstools/postcss-system-ui-font-family': 2.0.0(postcss@8.5.15) + '@csstools/postcss-text-decoration-shorthand': 5.0.3(postcss@8.5.15) + '@csstools/postcss-trigonometric-functions': 5.0.3(postcss@8.5.15) + '@csstools/postcss-unset-value': 5.0.0(postcss@8.5.15) + autoprefixer: 10.5.0(postcss@8.5.15) + browserslist: 4.28.2 + css-blank-pseudo: 8.0.1(postcss@8.5.15) + css-has-pseudo: 8.0.0(postcss@8.5.15) + css-prefers-color-scheme: 11.0.0(postcss@8.5.15) + cssdb: 8.9.0 + postcss: 8.5.15 + postcss-attribute-case-insensitive: 8.0.0(postcss@8.5.15) + postcss-clamp: 4.1.0(postcss@8.5.15) + postcss-color-functional-notation: 8.0.4(postcss@8.5.15) + postcss-color-hex-alpha: 11.0.0(postcss@8.5.15) + postcss-color-rebeccapurple: 11.0.0(postcss@8.5.15) + postcss-custom-media: 12.0.1(postcss@8.5.15) + postcss-custom-properties: 15.0.1(postcss@8.5.15) + postcss-custom-selectors: 9.0.1(postcss@8.5.15) + postcss-dir-pseudo-class: 10.0.0(postcss@8.5.15) + postcss-double-position-gradients: 7.0.1(postcss@8.5.15) + postcss-focus-visible: 11.0.0(postcss@8.5.15) + postcss-focus-within: 10.0.0(postcss@8.5.15) + postcss-font-variant: 5.0.0(postcss@8.5.15) + postcss-gap-properties: 7.0.0(postcss@8.5.15) + postcss-image-set-function: 8.0.0(postcss@8.5.15) + postcss-lab-function: 8.0.4(postcss@8.5.15) + postcss-logical: 9.0.0(postcss@8.5.15) + postcss-nesting: 14.0.0(postcss@8.5.15) + postcss-opacity-percentage: 3.0.0(postcss@8.5.15) + postcss-overflow-shorthand: 7.0.0(postcss@8.5.15) + postcss-page-break: 3.0.4(postcss@8.5.15) + postcss-place: 11.0.0(postcss@8.5.15) + postcss-pseudo-class-any-link: 11.0.0(postcss@8.5.15) + postcss-replace-overflow-wrap: 4.0.0(postcss@8.5.15) + postcss-selector-not: 9.0.0(postcss@8.5.15) + + postcss-pseudo-class-any-link@11.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-selector-parser: 7.1.1 + + postcss-reduce-initial@8.0.0(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + caniuse-api: 3.0.0 + postcss: 8.5.15 + + postcss-reduce-transforms@8.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + postcss-replace-overflow-wrap@4.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + + postcss-selector-not@9.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-selector-parser: 7.1.1 + + postcss-selector-parser@7.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-svgo@8.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + svgo: 4.0.1 + + postcss-unique-selectors@8.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-selector-parser: 7.1.1 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prismjs@1.30.0: {} + + property-information@7.1.0: {} + + radix3@1.1.2: {} + + range-parser@1.2.1: {} + + readdirp@5.0.0: {} + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + rehype-parse@9.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-from-html: 2.0.3 + unified: 11.0.5 + + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + + rehype-stringify@10.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + unified: 11.0.5 + + rehype@13.0.2: + dependencies: + '@types/hast': 3.0.4 + rehype-parse: 9.0.1 + rehype-stringify: 10.0.1 + unified: 11.0.5 + + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-smartypants@3.0.2: + dependencies: + retext: 9.0.0 + retext-smartypants: 6.2.0 + unified: 11.0.5 + unist-util-visit: 5.1.0 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + + resolve-pkg-maps@1.0.0: {} + + retext-latin@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + parse-latin: 7.0.0 + unified: 11.0.5 + + retext-smartypants@6.2.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unist-util-visit: 5.1.0 + + retext-stringify@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unified: 11.0.5 + + retext@9.0.0: + dependencies: + '@types/nlcst': 2.0.3 + retext-latin: 4.0.0 + retext-stringify: 4.0.0 + unified: 11.0.5 + + rollup@4.60.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 + fsevents: 2.3.3 + + sass@1.100.0: + dependencies: + chokidar: 5.0.0 + immutable: 5.1.5 + source-map-js: 1.2.1 + optionalDependencies: + '@parcel/watcher': 2.5.6 + + sax@1.6.0: {} + + semver@7.8.1: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + server-destroy@1.0.1: {} + + setprototypeof@1.2.0: {} + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.1 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + + shiki@4.1.0: + dependencies: + '@shikijs/core': 4.1.0 + '@shikijs/engine-javascript': 4.1.0 + '@shikijs/engine-oniguruma': 4.1.0 + '@shikijs/langs': 4.1.0 + '@shikijs/themes': 4.1.0 + '@shikijs/types': 4.1.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + sisteransi@1.0.5: {} + + smol-toml@1.6.1: {} + + source-map-js@1.2.1: {} + + space-separated-tokens@2.0.2: {} + + statuses@2.0.2: {} + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + stylehacks@8.0.0(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + postcss: 8.5.15 + postcss-selector-parser: 7.1.1 + + svgo@4.0.1: + dependencies: + commander: 11.1.0 + css-select: 5.2.2 + css-tree: 3.2.1 + css-what: 6.2.2 + csso: 5.0.5 + picocolors: 1.1.1 + sax: 1.6.0 + + tiny-inflate@1.0.3: {} + + tinyclip@0.1.12: {} + + tinyexec@1.2.2: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + toidentifier@1.0.1: {} + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + tslib@2.8.1: + optional: true + + ufo@1.6.4: {} + + ultrahtml@1.6.0: {} + + uncrypto@0.1.3: {} + + undici-types@7.24.6: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unifont@0.7.4: + dependencies: + css-tree: 3.2.1 + ofetch: 1.5.1 + ohash: 2.0.11 + + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-modify-children@4.0.0: + dependencies: + '@types/unist': 3.0.3 + array-iterate: 2.0.1 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-children@3.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + unstorage@1.17.5: + dependencies: + anymatch: 3.1.3 + chokidar: 5.0.0 + destr: 2.0.5 + h3: 1.15.11 + lru-cache: 11.5.0 + node-fetch-native: 1.6.7 + ofetch: 1.5.1 + ufo: 1.6.4 + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + util-deprecate@1.0.2: {} + + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@7.3.3(@types/node@25.9.1)(sass@1.100.0): + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.60.4 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.9.1 + fsevents: 2.3.3 + sass: 1.100.0 + + vitefu@1.1.3(vite@7.3.3(@types/node@25.9.1)(sass@1.100.0)): + optionalDependencies: + vite: 7.3.3(@types/node@25.9.1)(sass@1.100.0) + + web-namespaces@2.0.1: {} + + which-pm-runs@1.1.0: {} + + xxhash-wasm@1.1.0: {} + + yargs-parser@22.0.0: {} + + yocto-queue@1.2.2: {} + + zod@4.4.3: {} + + zwitch@2.0.4: {} diff --git a/Store/pnpm-workspace.yaml b/Store/pnpm-workspace.yaml new file mode 100644 index 0000000..b436ebe --- /dev/null +++ b/Store/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +allowBuilds: + "@parcel/watcher": true + esbuild: true + sharp: true diff --git a/Store/postcss.config.mjs b/Store/postcss.config.mjs new file mode 100644 index 0000000..cb5e571 --- /dev/null +++ b/Store/postcss.config.mjs @@ -0,0 +1,7 @@ +import autoprefixer from "autoprefixer"; +import cssnano from "cssnano"; +import postcssPresetEnv from "postcss-preset-env"; + +export default { + plugins: [autoprefixer(), postcssPresetEnv(), cssnano()], +}; diff --git a/Store/public/favicon/README.txt b/Store/public/favicon/README.txt new file mode 100644 index 0000000..bda570f --- /dev/null +++ b/Store/public/favicon/README.txt @@ -0,0 +1,43 @@ +FAVICON PACKAGE FOR: Royal-Pop-Accesory +Generated by ConvertICO.com - Favicon Package Generator +============================================================ + +CONTENTS: +--------- +- favicon.ico Multi-size ICO (16x16, 32x32, 48x48) +- favicon-16x16.png Browser tab favicon +- favicon-32x32.png High-DPI browser favicon +- favicon-48x48.png Windows site icon +- apple-touch-icon.png iOS home screen (180x180) +- apple-touch-icon-152x152.png iPad +- apple-touch-icon-120x120.png iPhone +- apple-touch-icon-76x76.png iPad mini +- android-chrome-192x192.png Android home screen +- android-chrome-512x512.png Android splash screen +- mstile-150x150.png Windows tile +- mstile-310x310.png Windows large tile +- site.webmanifest Web app manifest (Android PWA) +- browserconfig.xml Microsoft browser config + +INSTALLATION: +------------- +1. Upload all files to your website's /favicon directory. + +2. Add this code to your HTML section: + + + + + + + + + +NOTES: +------ +- This project serves all favicon assets from /favicon for cleaner public asset organization +- Modern browsers prefer PNG favicons but ICO provides legacy support +- site.webmanifest enables 'Add to Home Screen' on Android +- browserconfig.xml enables Windows Start menu tiles + +Need more sizes or formats? Visit https://convertico.com for more tools! diff --git a/Store/public/favicon/android-chrome-192x192.png b/Store/public/favicon/android-chrome-192x192.png new file mode 100644 index 0000000..316756d Binary files /dev/null and b/Store/public/favicon/android-chrome-192x192.png differ diff --git a/Store/public/favicon/android-chrome-512x512.png b/Store/public/favicon/android-chrome-512x512.png new file mode 100644 index 0000000..d02df59 Binary files /dev/null and b/Store/public/favicon/android-chrome-512x512.png differ diff --git a/Store/public/favicon/apple-touch-icon-120x120.png b/Store/public/favicon/apple-touch-icon-120x120.png new file mode 100644 index 0000000..38dbe2d Binary files /dev/null and b/Store/public/favicon/apple-touch-icon-120x120.png differ diff --git a/Store/public/favicon/apple-touch-icon-152x152.png b/Store/public/favicon/apple-touch-icon-152x152.png new file mode 100644 index 0000000..f397594 Binary files /dev/null and b/Store/public/favicon/apple-touch-icon-152x152.png differ diff --git a/Store/public/favicon/apple-touch-icon-76x76.png b/Store/public/favicon/apple-touch-icon-76x76.png new file mode 100644 index 0000000..f9b24c9 Binary files /dev/null and b/Store/public/favicon/apple-touch-icon-76x76.png differ diff --git a/Store/public/favicon/apple-touch-icon.png b/Store/public/favicon/apple-touch-icon.png new file mode 100644 index 0000000..d096b9c Binary files /dev/null and b/Store/public/favicon/apple-touch-icon.png differ diff --git a/Store/public/favicon/browserconfig.xml b/Store/public/favicon/browserconfig.xml new file mode 100644 index 0000000..51d9ae4 --- /dev/null +++ b/Store/public/favicon/browserconfig.xml @@ -0,0 +1,10 @@ + + + + + + + #e9e9e9 + + + diff --git a/Store/public/favicon/favicon-16x16.png b/Store/public/favicon/favicon-16x16.png new file mode 100644 index 0000000..ec3733e Binary files /dev/null and b/Store/public/favicon/favicon-16x16.png differ diff --git a/Store/public/favicon/favicon-32x32.png b/Store/public/favicon/favicon-32x32.png new file mode 100644 index 0000000..3cc7fe9 Binary files /dev/null and b/Store/public/favicon/favicon-32x32.png differ diff --git a/Store/public/favicon/favicon-48x48.png b/Store/public/favicon/favicon-48x48.png new file mode 100644 index 0000000..a7c7e13 Binary files /dev/null and b/Store/public/favicon/favicon-48x48.png differ diff --git a/Store/public/favicon/favicon.ico b/Store/public/favicon/favicon.ico new file mode 100644 index 0000000..b356e7d Binary files /dev/null and b/Store/public/favicon/favicon.ico differ diff --git a/Store/public/favicon/mstile-150x150.png b/Store/public/favicon/mstile-150x150.png new file mode 100644 index 0000000..eb49042 Binary files /dev/null and b/Store/public/favicon/mstile-150x150.png differ diff --git a/Store/public/favicon/mstile-310x310.png b/Store/public/favicon/mstile-310x310.png new file mode 100644 index 0000000..63eb00a Binary files /dev/null and b/Store/public/favicon/mstile-310x310.png differ diff --git a/Store/public/favicon/site.webmanifest b/Store/public/favicon/site.webmanifest new file mode 100644 index 0000000..fcdc245 --- /dev/null +++ b/Store/public/favicon/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "Royal-Pop-Accesory", + "short_name": "Royal-Pop-Accesory", + "icons": [ + { + "src": "/favicon/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/favicon/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#e9e9e9", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/Store/public/images/png/deep-blue.png b/Store/public/images/png/deep-blue.png new file mode 100644 index 0000000..c54cdb3 Binary files /dev/null and b/Store/public/images/png/deep-blue.png differ diff --git a/Store/public/images/png/light-blue.png b/Store/public/images/png/light-blue.png new file mode 100644 index 0000000..2fb21d3 Binary files /dev/null and b/Store/public/images/png/light-blue.png differ diff --git a/Store/public/images/png/lime-blue.png b/Store/public/images/png/lime-blue.png new file mode 100644 index 0000000..22ef9ba Binary files /dev/null and b/Store/public/images/png/lime-blue.png differ diff --git a/Store/public/images/png/ocho-negro.png b/Store/public/images/png/ocho-negro.png new file mode 100644 index 0000000..ddcbda1 Binary files /dev/null and b/Store/public/images/png/ocho-negro.png differ diff --git a/Store/public/images/png/pop-pink.png b/Store/public/images/png/pop-pink.png new file mode 100644 index 0000000..faee182 Binary files /dev/null and b/Store/public/images/png/pop-pink.png differ diff --git a/Store/public/images/png/pure-white.png b/Store/public/images/png/pure-white.png new file mode 100644 index 0000000..3985691 Binary files /dev/null and b/Store/public/images/png/pure-white.png differ diff --git a/Store/public/images/png/racer-green.png b/Store/public/images/png/racer-green.png new file mode 100644 index 0000000..d6331bf Binary files /dev/null and b/Store/public/images/png/racer-green.png differ diff --git a/Store/public/images/png/sorbet-multi.png b/Store/public/images/png/sorbet-multi.png new file mode 100644 index 0000000..6b110da Binary files /dev/null and b/Store/public/images/png/sorbet-multi.png differ diff --git a/Store/public/images/royal-pop/colorways/deep-blue-orange.webp b/Store/public/images/royal-pop/colorways/deep-blue-orange.webp new file mode 100644 index 0000000..18af4c8 Binary files /dev/null and b/Store/public/images/royal-pop/colorways/deep-blue-orange.webp differ diff --git a/Store/public/images/royal-pop/colorways/light-blue-sprint.webp b/Store/public/images/royal-pop/colorways/light-blue-sprint.webp new file mode 100644 index 0000000..60d1508 Binary files /dev/null and b/Store/public/images/royal-pop/colorways/light-blue-sprint.webp differ diff --git a/Store/public/images/royal-pop/colorways/lime-blue.webp b/Store/public/images/royal-pop/colorways/lime-blue.webp new file mode 100644 index 0000000..863bee1 Binary files /dev/null and b/Store/public/images/royal-pop/colorways/lime-blue.webp differ diff --git a/Store/public/images/royal-pop/colorways/ocho-negro.webp b/Store/public/images/royal-pop/colorways/ocho-negro.webp new file mode 100644 index 0000000..4a4e420 Binary files /dev/null and b/Store/public/images/royal-pop/colorways/ocho-negro.webp differ diff --git a/Store/public/images/royal-pop/colorways/pop-pink.webp b/Store/public/images/royal-pop/colorways/pop-pink.webp new file mode 100644 index 0000000..c9380cb Binary files /dev/null and b/Store/public/images/royal-pop/colorways/pop-pink.webp differ diff --git a/Store/public/images/royal-pop/colorways/pure-white.webp b/Store/public/images/royal-pop/colorways/pure-white.webp new file mode 100644 index 0000000..cfae44b Binary files /dev/null and b/Store/public/images/royal-pop/colorways/pure-white.webp differ diff --git a/Store/public/images/royal-pop/colorways/racer-green.webp b/Store/public/images/royal-pop/colorways/racer-green.webp new file mode 100644 index 0000000..0dea49e Binary files /dev/null and b/Store/public/images/royal-pop/colorways/racer-green.webp differ diff --git a/Store/public/images/royal-pop/colorways/sorbet-pop-multi-color.webp b/Store/public/images/royal-pop/colorways/sorbet-pop-multi-color.webp new file mode 100644 index 0000000..de5ce3a Binary files /dev/null and b/Store/public/images/royal-pop/colorways/sorbet-pop-multi-color.webp differ diff --git a/Store/public/images/royal-pop/editorial/bioceramic-strap.webp b/Store/public/images/royal-pop/editorial/bioceramic-strap.webp new file mode 100644 index 0000000..a94331a Binary files /dev/null and b/Store/public/images/royal-pop/editorial/bioceramic-strap.webp differ diff --git a/Store/public/images/royal-pop/editorial/color-board-english.webp b/Store/public/images/royal-pop/editorial/color-board-english.webp new file mode 100644 index 0000000..33f8ff5 Binary files /dev/null and b/Store/public/images/royal-pop/editorial/color-board-english.webp differ diff --git a/Store/public/images/royal-pop/editorial/hero-watch.webp b/Store/public/images/royal-pop/editorial/hero-watch.webp new file mode 100644 index 0000000..d53d020 Binary files /dev/null and b/Store/public/images/royal-pop/editorial/hero-watch.webp differ diff --git a/Store/public/images/royal-pop/editorial/scene-01.webp b/Store/public/images/royal-pop/editorial/scene-01.webp new file mode 100644 index 0000000..a63a1de Binary files /dev/null and b/Store/public/images/royal-pop/editorial/scene-01.webp differ diff --git a/Store/public/images/royal-pop/editorial/scene-02.webp b/Store/public/images/royal-pop/editorial/scene-02.webp new file mode 100644 index 0000000..264a30e Binary files /dev/null and b/Store/public/images/royal-pop/editorial/scene-02.webp differ diff --git a/Store/public/images/royal-pop/gallery/deep-blue-orange-card.png b/Store/public/images/royal-pop/gallery/deep-blue-orange-card.png new file mode 100644 index 0000000..7939780 Binary files /dev/null and b/Store/public/images/royal-pop/gallery/deep-blue-orange-card.png differ diff --git a/Store/public/images/royal-pop/gallery/deep-blue-orange-card.webp b/Store/public/images/royal-pop/gallery/deep-blue-orange-card.webp new file mode 100644 index 0000000..c07ea35 Binary files /dev/null and b/Store/public/images/royal-pop/gallery/deep-blue-orange-card.webp differ diff --git a/Store/public/images/royal-pop/gallery/light-blue-sprint-card.png b/Store/public/images/royal-pop/gallery/light-blue-sprint-card.png new file mode 100644 index 0000000..83b16d6 Binary files /dev/null and b/Store/public/images/royal-pop/gallery/light-blue-sprint-card.png differ diff --git a/Store/public/images/royal-pop/gallery/light-blue-sprint-card.webp b/Store/public/images/royal-pop/gallery/light-blue-sprint-card.webp new file mode 100644 index 0000000..8ceb723 Binary files /dev/null and b/Store/public/images/royal-pop/gallery/light-blue-sprint-card.webp differ diff --git a/Store/public/images/royal-pop/gallery/lime-blue-card.webp b/Store/public/images/royal-pop/gallery/lime-blue-card.webp new file mode 100644 index 0000000..1734e85 Binary files /dev/null and b/Store/public/images/royal-pop/gallery/lime-blue-card.webp differ diff --git a/Store/public/images/royal-pop/gallery/ocho-negro-card.webp b/Store/public/images/royal-pop/gallery/ocho-negro-card.webp new file mode 100644 index 0000000..448330d Binary files /dev/null and b/Store/public/images/royal-pop/gallery/ocho-negro-card.webp differ diff --git a/Store/public/images/royal-pop/gallery/pop-pink-card.webp b/Store/public/images/royal-pop/gallery/pop-pink-card.webp new file mode 100644 index 0000000..8da8ba0 Binary files /dev/null and b/Store/public/images/royal-pop/gallery/pop-pink-card.webp differ diff --git a/Store/public/images/royal-pop/gallery/pure-white-card.webp b/Store/public/images/royal-pop/gallery/pure-white-card.webp new file mode 100644 index 0000000..9e47f88 Binary files /dev/null and b/Store/public/images/royal-pop/gallery/pure-white-card.webp differ diff --git a/Store/public/images/royal-pop/gallery/racer-green-card.webp b/Store/public/images/royal-pop/gallery/racer-green-card.webp new file mode 100644 index 0000000..35190e5 Binary files /dev/null and b/Store/public/images/royal-pop/gallery/racer-green-card.webp differ diff --git a/Store/public/images/royal-pop/gallery/sorbet-pop-multi-color-card.webp b/Store/public/images/royal-pop/gallery/sorbet-pop-multi-color-card.webp new file mode 100644 index 0000000..87a1e7c Binary files /dev/null and b/Store/public/images/royal-pop/gallery/sorbet-pop-multi-color-card.webp differ diff --git a/Store/public/robots.txt b/Store/public/robots.txt new file mode 100644 index 0000000..21b1977 --- /dev/null +++ b/Store/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://royal-pop-accessory.com/sitemap.xml diff --git a/Store/public/scripts/royal-pop-cart.js b/Store/public/scripts/royal-pop-cart.js new file mode 100644 index 0000000..5c2415c --- /dev/null +++ b/Store/public/scripts/royal-pop-cart.js @@ -0,0 +1,190 @@ +(function () { + const ROYAL_POP_CART_KEY = "royal-pop-cart"; + const ROYAL_POP_BUILDER_KEY = "royal-pop-builder-selection"; + const ROYAL_POP_CART_LIMIT = Number.POSITIVE_INFINITY; + + const isBrowser = () => typeof window !== "undefined" && typeof window.localStorage !== "undefined"; + + const readJson = (key, fallback) => { + if (!isBrowser()) return fallback; + + try { + const raw = window.localStorage.getItem(key); + if (!raw) return fallback; + return JSON.parse(raw); + } catch { + return fallback; + } + }; + + const writeJson = (key, value) => { + if (!isBrowser()) return; + + try { + window.localStorage.setItem(key, JSON.stringify(value)); + } catch { + // Ignore storage write failures. + } + }; + + const createItemId = () => { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + + return `rp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + }; + + const normalizeSelection = (selection) => ({ + style: selection && selection.style === "A" ? "A" : "B", + colorwayId: (selection && selection.colorwayId) || "sorbet-pop-multi-color", + finishId: (selection && selection.finishId) || "silver", + quantity: Math.max(1, Math.round(Number(selection && selection.quantity) || 1)), + }); + + const loadBuilderSelection = () => { + const selection = readJson(ROYAL_POP_BUILDER_KEY, null); + if (!selection) return null; + return normalizeSelection(selection); + }; + + const saveBuilderSelection = (selection) => { + writeJson(ROYAL_POP_BUILDER_KEY, normalizeSelection(selection)); + }; + + const loadCart = () => { + const items = readJson(ROYAL_POP_CART_KEY, []); + + return items + .filter((item) => item && item.colorwayId && item.finishId) + .map((item) => ({ + id: item.id || createItemId(), + style: item.style === "A" ? "A" : "B", + colorwayId: String(item.colorwayId), + finishId: String(item.finishId), + quantity: Math.max(1, Math.round(Number(item.quantity) || 1)), + })) + ; + }; + + const saveCart = (items) => { + writeJson( + ROYAL_POP_CART_KEY, + items.map((item) => ({ + ...item, + quantity: Math.max(1, Math.round(Number(item.quantity) || 1)), + })), + ); + }; + + const getCartCount = () => loadCart().reduce((sum, item) => sum + Math.max(1, Number(item.quantity) || 1), 0); + + const addSelectionToCart = (selection) => { + const normalized = normalizeSelection(selection || {}); + const currentCart = loadCart(); + const matchingIndex = currentCart.findIndex( + (item) => + item.style === normalized.style && + item.colorwayId === normalized.colorwayId && + item.finishId === normalized.finishId, + ); + + let nextCart; + if (matchingIndex >= 0) { + nextCart = currentCart.map((item, index) => + index === matchingIndex + ? { + ...item, + quantity: Math.max(1, Number(item.quantity) || 1) + normalized.quantity, + } + : item, + ); + } else { + nextCart = [ + ...currentCart, + { + id: createItemId(), + style: normalized.style, + colorwayId: normalized.colorwayId, + finishId: normalized.finishId, + quantity: normalized.quantity, + }, + ]; + } + saveCart(nextCart); + + return { + cart: nextCart, + addedCount: normalized.quantity, + isFull: false, + }; + }; + + const removeCartItem = (itemId) => { + const currentCart = loadCart(); + const nextCart = currentCart.filter((item) => item.id !== itemId); + saveCart(nextCart); + return nextCart; + }; + + const getEffectiveCart = () => { + const cart = loadCart(); + if (cart.length > 0) return cart; + + const selection = loadBuilderSelection(); + if (!selection) return []; + + return [{ + id: createItemId(), + style: selection.style, + colorwayId: selection.colorwayId, + finishId: selection.finishId, + quantity: selection.quantity, + }]; + }; + + const groupCartItems = (items) => { + const groups = new Map(); + + (items || []).forEach((item) => { + const key = `${item.style}::${item.colorwayId}::${item.finishId}`; + const current = groups.get(key); + const itemQuantity = Math.max(1, Math.round(Number(item.quantity) || 1)); + + if (current) { + current.quantity += itemQuantity; + return; + } + + groups.set(key, { + style: item.style, + colorwayId: item.colorwayId, + finishId: item.finishId, + quantity: itemQuantity, + }); + }); + + return Array.from(groups.values()); + }; + + const clearCart = () => { + if (!isBrowser()) return; + window.localStorage.removeItem(ROYAL_POP_CART_KEY); + }; + + window.RoyalPopCart = { + ROYAL_POP_CART_KEY, + ROYAL_POP_BUILDER_KEY, + ROYAL_POP_CART_LIMIT, + loadBuilderSelection, + saveBuilderSelection, + loadCart, + saveCart, + getCartCount, + addSelectionToCart, + removeCartItem, + getEffectiveCart, + groupCartItems, + clearCart, + }; +})(); diff --git a/Store/public/sitemap.xml b/Store/public/sitemap.xml new file mode 100644 index 0000000..4b0de43 --- /dev/null +++ b/Store/public/sitemap.xml @@ -0,0 +1,9 @@ + + + + https://royal-pop-accessory.com/ + + + https://royal-pop-accessory.com/buy + + diff --git a/Store/src/components/royal-pop/ColorwaysSection.astro b/Store/src/components/royal-pop/ColorwaysSection.astro new file mode 100644 index 0000000..ef4c546 --- /dev/null +++ b/Store/src/components/royal-pop/ColorwaysSection.astro @@ -0,0 +1,151 @@ +--- +// Path: Store/src/components/royal-pop/ColorwaysSection.astro +import type { RoyalPopColorway } from "../../data/royalPop"; +import { defaultColorway } from "../../data/royalPop"; +import styles from "./ColorwaysSection.module.scss"; + +interface Props { + colorways: RoyalPopColorway[]; +} + +const { colorways } = Astro.props; +const active = defaultColorway; +--- + +
+
+
+

The Palette

+

+ Eight ways +
+ to stand out. +

+

Each Royal Pop conversion kit ships as a complete cohesive system - strap, lug adapter, and matched case build - tied to your chosen colourway.

+
+ +
+
+ {`${active.name} +
+ +
+

{active.name}

+

{active.subtitle}

+
+ + {active.styleText} +
+

{active.crownDescription}

+
+ +
+ { + colorways.map((colorway) => ( +
+ +
+ { + colorways.map((colorway) => ( + + )) + } +
+
+
+ + +
diff --git a/Store/src/components/royal-pop/ColorwaysSection.module.scss b/Store/src/components/royal-pop/ColorwaysSection.module.scss new file mode 100644 index 0000000..c97d393 --- /dev/null +++ b/Store/src/components/royal-pop/ColorwaysSection.module.scss @@ -0,0 +1,236 @@ + +.section { + padding: 120px 24px; + background: #ffffff; +} + +.inner { + width: min(100%, 980px); + margin: 0 auto; +} + +.heading { + text-align: center; +} + +.sectionLabel { + margin: 0 0 18px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.18em; + text-transform: uppercase; + color: #6e6e73; +} + +.sectionHeadline { + margin: 0; + font-size: clamp(36px, 5vw, 56px); + font-weight: 700; + line-height: 1.02; + letter-spacing: -0.05em; + color: #1d1d1f; +} + +.sectionBody { + max-width: 620px; + margin: 20px auto 0; + font-size: 17px; + line-height: 1.7; + color: #6e6e73; +} + +.previewArea { + margin-top: 56px; + text-align: center; +} + +.previewCard { + max-width: 480px; + margin: 0 auto; + padding: 40px; + border-radius: 32px; + transition: background 0.25s ease; +} + +.watchImage { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + object-position: center; +} + +.previewText { + margin-top: 28px; + display: flex; + flex-direction: column; + align-items: center; +} + +.colorName { + margin: 0; + font-size: clamp(24px, 3vw, 32px); + font-weight: 700; + letter-spacing: -0.03em; + color: #1d1d1f; +} + +.colorSubtitle { + margin: 6px 0 0; + font-size: 17px; + color: #6e6e73; +} + +.styleBadge { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 18px; + padding: 6px 14px; + border-radius: 999px; + font-size: 13px; + font-weight: 500; + + &[data-style="a"] { + background: #e8f2ff; + color: #0071e3; + } + + &[data-style="b"] { + background: #fdeced; + color: #d14d52; + } +} + +.dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + flex: 0 0 auto; +} + +.crownDesc { + max-width: 360px; + margin: 14px auto 0; + font-size: 13px; + line-height: 1.6; + color: #6e6e73; +} + +.swatches { + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 12px; + margin-top: 24px; +} + +.colorSwatch { + width: 28px; + height: 28px; + padding: 0; + border: 2px solid transparent; + border-radius: 50%; + cursor: pointer; + transition: + transform 0.2s ease, + box-shadow 0.2s ease, + border-color 0.2s ease; + + &:hover { + transform: scale(1.08); + } + + &[aria-pressed="true"], + &:global(.is-active) { + border-color: #1d1d1f; + box-shadow: 0 0 0 3px #ffffff, 0 0 0 4px #1d1d1f; + } +} + +.cardGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 28px 20px; + margin-top: 56px; + + @include respond(tablet) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +.gridCard { + padding: 0; + border: 0; + background: transparent; + font: inherit; + color: inherit; + text-align: center; + cursor: pointer; + appearance: none; + -webkit-appearance: none; + text-align: center; + transition: transform 0.22s ease; + + &:focus-visible { + outline: 2px solid #1d1d1f; + outline-offset: 6px; + border-radius: 20px; + } + + &:hover .gridCardImageWrap { + transform: translateY(-4px) scale(1.015); + } + + &[aria-pressed="true"] .gridCardImageWrap, + &:global(.is-active) .gridCardImageWrap { + box-shadow: 0 0 0 2px #1d1d1f; + transform: translateY(-4px) scale(1.015); + } + + &[aria-pressed="true"] strong, + &:global(.is-active) strong { + color: #0071e3; + } +} + +.gridCardImageWrap { + padding: 22px; + border-radius: 16px; + background: #f5f5f7; + transition: transform 0.22s ease; +} + +.gridCardText { + margin-top: 12px; + + strong, + span { + display: block; + } + + strong { + font-size: 13px; + font-weight: 600; + letter-spacing: 0.08em; + color: #1d1d1f; + } + + span { + margin-top: 4px; + font-size: 14px; + color: #6e6e73; + } +} + +@media (max-width: 734px) { + .section { + padding-top: 88px; + padding-bottom: 88px; + } + + .previewCard { + padding: 28px; + } +} diff --git a/Store/src/components/royal-pop/FeatureSections.astro b/Store/src/components/royal-pop/FeatureSections.astro new file mode 100644 index 0000000..721bec5 --- /dev/null +++ b/Store/src/components/royal-pop/FeatureSections.astro @@ -0,0 +1,62 @@ +--- +import styles from "./FeatureSections.module.scss"; +--- + +
+
+
+
+

Engineering

+

+ Built for the +
+ Lépine crown. +

+

+ The Royal Pop's Lépine-style pocket watch case wears its crown at 12 o'clock - a design that demands a purpose-built conversion system. Our precision lug adapter is CNC-machined to align the octagonal case perfectly on + the wrist, ±0.1° crown-true, every time. +

+ +
+
+ ±0.1° + Alignment tolerance +
+
+ CNC + Machined lug adapter +
+
+
+ +
+ Royal Pop case shown inside the precision adapter +
+
+
+
+ +
+
+
+
+ Royal Pop bioceramic case shell and silicone strap detail +
+ +
+

Materials

+

+ Built to the +
+ same standard. +

+

+ The conversion kit's case is formed from the same Premium Bioceramic compound Swatch uses in the Royal Pop itself - lightweight, scratch-resistant, and warm to the touch. The silicone strap is custom-moulded to the Royal + Pop's octagonal integrated lug geometry for a flush, factory-finished fit. +

+

Every kit ships as a matched system: Bioceramic case shell, high-grade silicone strap, and Royal Pop-specific fitment - all matched to your chosen colourway.

+
+
+ +
+
diff --git a/Store/src/components/royal-pop/FeatureSections.module.scss b/Store/src/components/royal-pop/FeatureSections.module.scss new file mode 100644 index 0000000..4313b41 --- /dev/null +++ b/Store/src/components/royal-pop/FeatureSections.module.scss @@ -0,0 +1,411 @@ + +.featureSection { + padding: 120px 24px; + background: #ffffff; +} + +.materialSection { + padding-top: 0; +} + +.inner { + width: min(100%, 980px); + margin: 0 auto; +} + +.splitGrid { + display: grid; + gap: 48px; + align-items: center; + + @include respond(tablet) { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 80px; + } +} + +.copyBlock { + min-width: 0; +} + +.sectionLabel, +.sectionLabelDark { + margin: 0 0 18px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.sectionLabel { + color: #6e6e73; +} + +.sectionLabelDark { + color: rgba(255, 255, 255, 0.62); +} + +.sectionHeadline, +.sectionHeadlineDark { + margin: 0; + font-size: clamp(36px, 5vw, 56px); + font-weight: 700; + line-height: 1.02; + letter-spacing: -0.05em; +} + +.sectionHeadline { + color: #1d1d1f; +} + +.sectionHeadlineDark { + color: #ffffff; +} + +.sectionBody, +.sectionBodyDark { + max-width: 480px; + margin: 20px 0 0; + font-size: 17px; + line-height: 1.7; +} + +.sectionBody { + color: #6e6e73; +} + +.sectionBodyDark { + color: rgba(255, 255, 255, 0.72); +} + +.mediaFrame { + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 40px; + border-radius: 32px; + border: 1px solid rgba(210, 210, 215, 0.7); + background: linear-gradient(180deg, #fbfbfd 0%, #f5f5f7 100%); + box-shadow: 0 18px 44px rgba(29, 29, 31, 0.06); + overflow: hidden; +} + +.watchImage, +.detailImage { + width: 100%; + height: 100%; + object-fit: contain; + object-position: center; +} + +.metricRow { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + margin-top: 32px; +} + +.metricItem, +.darkMetricItem { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 6px; + min-height: 118px; + padding: 20px; + border-radius: 24px; +} + +.metricItem strong, +.darkMetricItem strong { + font-size: 28px; + font-weight: 700; + letter-spacing: -0.04em; + line-height: 1; +} + +.metricItem span, +.darkMetricItem span { + font-size: 13px; + line-height: 1.5; +} + +.metricItem strong { + color: #1d1d1f; +} + +.metricItem span { + color: #6e6e73; +} + +.metricItem { + border: 1px solid rgba(210, 210, 215, 0.8); + background: linear-gradient(180deg, #fbfbfd 0%, #f5f5f7 100%); + box-shadow: 0 14px 32px rgba(29, 29, 31, 0.05); +} + +.comparisonCard { + margin-top: 48px; + border: 1px solid #d2d2d7; + border-radius: 28px; + overflow: hidden; + background: #ffffff; + box-shadow: 0 18px 44px rgba(29, 29, 31, 0.05); +} + +.cardHeader, +.cardRow { + display: grid; + grid-template-columns: 1.1fr 1fr 1fr; + gap: 16px; + align-items: center; + padding: 16px 24px; +} + +.cardHeader { + background: linear-gradient(180deg, #f9f9fb 0%, #f3f3f5 100%); + font-size: 13px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #6e6e73; +} + +.cardRows { + background: #ffffff; +} + +.cardRow { + padding: 14px 24px; + font-size: 15px; + color: #1d1d1f; + border-top: 1px solid #f0f0f0; +} + +.cardRow span:first-child { + font-weight: 600; +} + +.cardRowAlt { + background: #fafafa; +} + +.darkBand { + padding: 120px 24px; + background: #1d1d1f; +} + +.darkBandGrid { + display: grid; + gap: 40px; + align-items: center; + + @include respond(tablet) { + grid-template-columns: minmax(0, 1fr) auto; + } +} + +.darkMetrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; +} + +.darkMetricItem strong { + color: #ffffff; +} + +.darkMetricItem span { + color: rgba(255, 255, 255, 0.62); +} + +.darkMetricItem { + border: 1px solid rgba(255, 255, 255, 0.1); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.03) 100%); + backdrop-filter: blur(8px); +} + +@media (max-width: 900px) { + .comparisonCard { + margin-top: 36px; + border-radius: 24px; + } + + .cardHeader { + display: none; + } + + .cardRows { + display: grid; + gap: 12px; + padding: 14px; + background: linear-gradient(180deg, #fbfbfd 0%, #f4f4f6 100%); + } + + .cardRow { + grid-template-columns: minmax(0, 1fr); + grid-template-areas: + "label" + "ours" + "standard"; + gap: 10px; + padding: 16px; + font-size: 13px; + line-height: 1.4; + border: 1px solid rgba(210, 210, 215, 0.8); + border-radius: 20px; + background: linear-gradient(180deg, #ffffff 0%, #fafafc 100%); + box-shadow: 0 10px 24px rgba(29, 29, 31, 0.05); + } + + .cardRowAlt { + background: linear-gradient(180deg, #ffffff 0%, #fafafc 100%); + } + + .cardRow span { + min-width: 0; + overflow-wrap: anywhere; + } + + .cardRow span:first-child { + grid-area: label; + font-size: 15px; + font-weight: 700; + letter-spacing: -0.01em; + } + + .cardRow span:nth-child(2), + .cardRow span:nth-child(3) { + display: flex; + flex-direction: column; + justify-content: flex-start; + gap: 6px; + padding: 12px 14px; + border-radius: 14px; + background: #f5f5f7; + font-size: 14px; + line-height: 1.45; + } + + .cardRow span:nth-child(2) { + grid-area: ours; + } + + .cardRow span:nth-child(3) { + grid-area: standard; + } + + .cardRow span:nth-child(2)::before, + .cardRow span:nth-child(3)::before { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #6e6e73; + } + + .cardRow span:nth-child(2)::before { + content: "Bioceramic (ours)"; + } + + .cardRow span:nth-child(3)::before { + content: "Standard plastic"; + } +} + +@media (max-width: 734px) { + .featureSection, + .darkBand { + padding-top: 72px; + padding-bottom: 72px; + } + + .materialSection { + padding-top: 0; + } + + .splitGrid { + gap: 28px; + } + + .sectionLabel, + .sectionLabelDark { + margin-bottom: 14px; + font-size: 11px; + letter-spacing: 0.16em; + } + + .sectionHeadline, + .sectionHeadlineDark { + font-size: clamp(31px, 9vw, 40px); + line-height: 1; + } + + .sectionBody, + .sectionBodyDark { + margin-top: 16px; + font-size: 15px; + line-height: 1.55; + } + + .mediaFrame { + padding: 24px; + border-radius: 24px; + } + + .metricRow { + gap: 12px; + margin-top: 24px; + } + + .darkMetrics { + gap: 12px; + } + + .metricItem, + .darkMetricItem { + min-height: 100px; + padding: 16px; + border-radius: 20px; + } + + .metricItem strong, + .darkMetricItem strong { + font-size: 24px; + } + + .metricItem span, + .darkMetricItem span { + font-size: 12px; + line-height: 1.35; + } + + .cardRow { + font-size: 13px; + line-height: 1.35; + } + + .cardRow span:first-child { + font-size: 14px; + } + + .cardRow span:nth-child(2), + .cardRow span:nth-child(3) { + font-size: 12px; + line-height: 1.4; + } + + .cardRow span:nth-child(2)::before, + .cardRow span:nth-child(3)::before { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #6e6e73; + } + + .darkBandGrid { + gap: 24px; + } +} diff --git a/Store/src/components/royal-pop/HeroSection.astro b/Store/src/components/royal-pop/HeroSection.astro new file mode 100644 index 0000000..c0c2b71 --- /dev/null +++ b/Store/src/components/royal-pop/HeroSection.astro @@ -0,0 +1,25 @@ +--- +// Path: Store/src/components/royal-pop/HeroSection.astro +import styles from "./HeroSection.module.scss"; +--- + +
+
+

Introducing the Conversion Kit

+

+ Wear the Pop +
+ on Your Wrist. +

+

The first precision-engineered wrist conversion kit for your Swatch × AP Royal Pop. Eight vibrant pop-art colorways. One obsessive luxury standard.

+ + + +
+ Royal Pop wrist conversion kit hero watch +
+
+
diff --git a/Store/src/components/royal-pop/HeroSection.module.scss b/Store/src/components/royal-pop/HeroSection.module.scss new file mode 100644 index 0000000..cef9c53 --- /dev/null +++ b/Store/src/components/royal-pop/HeroSection.module.scss @@ -0,0 +1,150 @@ +@keyframes floatWatch { + 0%, + 100% { + transform: translateY(0); + } + + 50% { + transform: translateY(-10px); + } +} + +.heroSection { + box-sizing: border-box; + min-height: 100dvh; + display: flex; + align-items: center; + justify-content: center; + padding: calc(env(safe-area-inset-top, 0px) + 72px) 24px 72px; + background: linear-gradient(180deg, #fbfbfd 0%, #f0f2f5 100%); + text-align: center; +} + +.inner { + width: min(100%, 980px); + margin: 0 auto; + display: flex; + flex-direction: column; + align-items: center; +} + +.eyebrow { + margin: 0 0 14px; + font-size: 14px; + font-weight: 600; + color: #0071e3; +} + +.headline { + margin: 0; + max-width: 760px; + font-size: clamp(48px, 7vw, 80px); + font-weight: 700; + line-height: 0.96; + letter-spacing: -0.055em; + color: #1d1d1f; +} + +.summary { + max-width: 560px; + margin: 22px 0 0; + font-size: clamp(18px, 2.5vw, 24px); + line-height: 1.45; + color: #6e6e73; +} + +.actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 16px; + margin-top: 28px; +} + +.primaryButton, +.secondaryButton { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 12px 22px; + border-radius: 980px; + font-size: 17px; + font-weight: 400; + text-decoration: none; + transition: + background-color 0.2s ease, + color 0.2s ease; +} + +.primaryButton { + background: #0071e3; + color: #ffffff; + + &:hover { + background: #0077ed; + text-decoration: none; + } +} + +.secondaryButton { + color: #0071e3; + background: transparent; + + &:hover { + background: rgba(0, 113, 227, 0.08); + text-decoration: none; + } +} + +.heroImageWrap { + width: min(100%, 620px); + margin-top: 48px; +} + +.watchImage { + display: block; + width: 100%; + height: auto; + object-fit: contain; + object-position: center; + filter: drop-shadow(0 24px 48px rgba(0, 0, 0, 0.18)); + animation: floatWatch 5.5s ease-in-out infinite; +} + +@media (max-width: 734px) { + .heroSection { + padding: calc(env(safe-area-inset-top, 0px) + 72px) 20px 36px; + } + + .inner { + max-width: 420px; + } + + .headline { + font-size: 36px; + line-height: 0.98; + } + + .summary { + max-width: 34ch; + margin-top: 16px; + font-size: 18px; + line-height: 1.35; + } + + .actions { + gap: 12px; + margin-top: 20px; + } + + .primaryButton, + .secondaryButton { + padding: 11px 18px; + font-size: 16px; + } + + .heroImageWrap { + width: min(100%, 320px); + margin-top: 28px; + } +} diff --git a/Store/src/components/royal-pop/PreorderSection.astro b/Store/src/components/royal-pop/PreorderSection.astro new file mode 100644 index 0000000..88b3659 --- /dev/null +++ b/Store/src/components/royal-pop/PreorderSection.astro @@ -0,0 +1,35 @@ +--- +// Path: Store/src/components/royal-pop/PreorderSection.astro +import styles from "./PreorderSection.module.scss"; +--- + +
+
+

Early Bird

+

+ Reserve +
+ your kit. +

+

Pre-orders typically ship in around 1 month. Each kit is matched to a specific Royal Pop colourway — lock in yours before the £49.99 early bird closes.

+ +
+
+ £49.99 + Early Bird Price +
+ vs +
+ £89.99 + Retail Price +
+
+ + + +

Free worldwide shipping · 30-day returns · Secure checkout

+
+
diff --git a/Store/src/components/royal-pop/PreorderSection.module.scss b/Store/src/components/royal-pop/PreorderSection.module.scss new file mode 100644 index 0000000..8c658a8 --- /dev/null +++ b/Store/src/components/royal-pop/PreorderSection.module.scss @@ -0,0 +1,137 @@ + +.section { + padding: 120px 24px; + background: #ffffff; +} + +.banner { + width: min(100%, 980px); + margin: 0 auto; + padding: 120px 24px; + border-radius: 32px; + background: linear-gradient(180deg, #1f1f23 0%, #101113 100%); + text-align: center; +} + +.sectionLabel { + margin: 0 0 18px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.18em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.72); +} + +.sectionHeadline { + margin: 0; + font-size: clamp(36px, 5vw, 56px); + font-weight: 700; + line-height: 1.02; + letter-spacing: -0.05em; + color: #ffffff; +} + +.sectionBody { + max-width: 620px; + margin: 20px auto 0; + font-size: 17px; + line-height: 1.7; + color: rgba(255, 255, 255, 0.72); +} + +.priceRow { + display: flex; + justify-content: center; + align-items: center; + flex-wrap: wrap; + gap: 16px 24px; + margin-top: 34px; +} + +.priceBlock { + display: flex; + flex-direction: column; + gap: 6px; + + strong { + font-size: clamp(34px, 5vw, 44px); + font-weight: 700; + letter-spacing: -0.04em; + color: #ffffff; + } + + span { + font-size: 14px; + color: rgba(255, 255, 255, 0.68); + } +} + +.retailPrice { + opacity: 0.56; + text-decoration: line-through; +} + +.vs { + font-size: 17px; + color: rgba(255, 255, 255, 0.52); +} + +.actions { + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 16px; + margin-top: 34px; +} + +.primaryButton, +.secondaryButton { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 12px 22px; + border-radius: 980px; + font-size: 17px; + text-decoration: none; + transition: + background-color 0.2s ease, + color 0.2s ease; +} + +.primaryButton { + background: #0071e3; + color: #ffffff; + + &:hover { + background: #0077ed; + text-decoration: none; + } +} + +.secondaryButton { + color: #2997ff; + background: transparent; + + &:hover { + background: rgba(41, 151, 255, 0.1); + text-decoration: none; + } +} + +.footerNote { + margin: 28px 0 0; + font-size: 14px; + color: rgba(255, 255, 255, 0.58); +} + +@media (max-width: 734px) { + .section { + padding-top: 88px; + padding-bottom: 88px; + } + + .banner { + padding-top: 88px; + padding-bottom: 88px; + } +} diff --git a/Store/src/components/royal-pop/SiteFooter.astro b/Store/src/components/royal-pop/SiteFooter.astro new file mode 100644 index 0000000..51efdad --- /dev/null +++ b/Store/src/components/royal-pop/SiteFooter.astro @@ -0,0 +1,16 @@ +--- +// Path: Store/src/components/royal-pop/SiteFooter.astro +import styles from "./SiteFooter.module.scss"; +--- + +
+
+
+

Royal Pop: Swatch × AP Wrist Conversion Kit

+
+ +
+ +

Not affiliated with Swatch Group AG or Audemars Piguet SA.

+
+
diff --git a/Store/src/components/royal-pop/SiteFooter.module.scss b/Store/src/components/royal-pop/SiteFooter.module.scss new file mode 100644 index 0000000..6e41a56 --- /dev/null +++ b/Store/src/components/royal-pop/SiteFooter.module.scss @@ -0,0 +1,51 @@ +.footer { + padding: 40px 24px; + background: #f5f5f7; + border-top: 1px solid #d2d2d7; + font-size: 12px; + color: #6e6e73; +} + +.inner { + width: min(100%, 980px); + margin: 0 auto; +} + +.topRow { + display: flex; + flex-direction: column; + gap: 16px; + + @include respond(tablet) { + flex-direction: row; + align-items: center; + justify-content: space-between; + } +} + +.productLabel, +.disclaimer { + margin: 0; + line-height: 1.6; +} + +.links { + display: flex; + flex-wrap: wrap; + gap: 16px; + + a { + color: #6e6e73; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} + +.divider { + height: 1px; + margin: 24px 0 16px; + background: #d2d2d7; +} diff --git a/Store/src/components/royal-pop/SpecsSection.astro b/Store/src/components/royal-pop/SpecsSection.astro new file mode 100644 index 0000000..70b03fa --- /dev/null +++ b/Store/src/components/royal-pop/SpecsSection.astro @@ -0,0 +1,41 @@ +--- +import type { SpecRow } from "../../data/royalPop"; +import styles from "./SpecsSection.module.scss"; + +interface Props { + rows: SpecRow[]; +} + +const { rows } = Astro.props; +--- + +
+
+
+
+

Details

+

+ Technical +
+ specifications. +

+

+ Every measurement exists for a reason. Zero compromises, zero guesswork. +

+
+ +
+ { + rows.map((row) => ( +
+ {row.label} + + {row.value} + +
+ )) + } +
+
+
+
diff --git a/Store/src/components/royal-pop/SpecsSection.module.scss b/Store/src/components/royal-pop/SpecsSection.module.scss new file mode 100644 index 0000000..ecf8e06 --- /dev/null +++ b/Store/src/components/royal-pop/SpecsSection.module.scss @@ -0,0 +1,88 @@ + +.section { + padding: 120px 24px; + background: #ffffff; +} + +.inner { + width: min(100%, 980px); + margin: 0 auto; +} + +.grid { + display: grid; + gap: 48px; + + @include respond(tablet) { + grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); + gap: 80px; + } +} + +.sectionLabel { + margin: 0 0 18px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.18em; + text-transform: uppercase; + color: #6e6e73; +} + +.sectionHeadline { + margin: 0; + font-size: clamp(36px, 5vw, 56px); + font-weight: 700; + line-height: 1.02; + letter-spacing: -0.05em; + color: #1d1d1f; +} + +.sectionBody { + max-width: 360px; + margin: 20px 0 0; + font-size: 17px; + line-height: 1.7; + color: #6e6e73; +} + +.specRows { + width: 100%; +} + +.specRow { + display: flex; + flex-direction: column; + gap: 10px; + padding: 18px 0; + border-bottom: 1px solid #d2d2d7; + + @include respond(mobile) { + flex-direction: row; + justify-content: space-between; + align-items: center; + gap: 24px; + } +} + +.specKey { + font-size: 15px; + color: #6e6e73; +} + +.specValue { + font-size: 15px; + font-weight: 500; + color: #1d1d1f; + text-align: left; + + @include respond(mobile) { + text-align: right; + } +} + +@media (max-width: 734px) { + .section { + padding-top: 88px; + padding-bottom: 88px; + } +} diff --git a/Store/src/components/royal-pop/StatsBand.astro b/Store/src/components/royal-pop/StatsBand.astro new file mode 100644 index 0000000..4f49a83 --- /dev/null +++ b/Store/src/components/royal-pop/StatsBand.astro @@ -0,0 +1,25 @@ +--- +import type { StatItem } from "../../data/royalPop"; +import styles from "./StatsBand.module.scss"; + +interface Props { + items: StatItem[]; +} + +const { items } = Astro.props; +--- + +
+
+
    + { + items.map((item) => ( +
  • + {item.value} + {item.label} +
  • + )) + } +
+
+
diff --git a/Store/src/components/royal-pop/StatsBand.module.scss b/Store/src/components/royal-pop/StatsBand.module.scss new file mode 100644 index 0000000..50234aa --- /dev/null +++ b/Store/src/components/royal-pop/StatsBand.module.scss @@ -0,0 +1,79 @@ + +.wrapper { + padding: 80px 24px; + background: #ffffff; +} + +.inner { + width: min(100%, 980px); + margin: 0 auto; +} + +.grid { + list-style: none; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + padding: 0; + margin: 0; + + @include respond(tablet) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +.item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 132px; + padding: 20px 28px; + border: 1px solid rgba(210, 210, 215, 0.8); + border-radius: 24px; + background: linear-gradient(180deg, #fbfbfd 0%, #f5f5f7 100%); + box-shadow: 0 12px 30px rgba(29, 29, 31, 0.05); + text-align: center; + + strong { + font-size: 36px; + font-weight: 700; + letter-spacing: -0.03em; + line-height: 1.05; + color: #1d1d1f; + } + + span { + margin-top: 6px; + font-size: 12px; + font-weight: 500; + letter-spacing: -0.01em; + color: #6e6e73; + } +} + +@media (max-width: 734px) { + .wrapper { + padding: 56px 20px 44px; + } + + .grid { + gap: 12px; + } + + .item { + min-height: 118px; + padding: 18px 14px; + border-radius: 20px; + + strong { + font-size: clamp(28px, 8vw, 34px); + } + + span { + margin-top: 5px; + font-size: 11px; + line-height: 1.35; + } + } +} diff --git a/Store/src/data/royalPop.ts b/Store/src/data/royalPop.ts new file mode 100644 index 0000000..3bd4d4a --- /dev/null +++ b/Store/src/data/royalPop.ts @@ -0,0 +1,196 @@ +// Path: Store/src/data/royalPop.ts + +export interface RoyalPopColorway { + id: string; + name: string; + subtitle: string; + style: "A" | "B"; + styleText: string; + crownDescription: string; + crownSpec: string; + crownStatValue: string; + crownStatLabel: string; + swatchColor: string; + previewBackground: string; + previewImage: string; + cardImage: string; +} + +export interface StatItem { + id?: string; + value: string; + label: string; +} + +export interface ComparisonRow { + label: string; + royalPop: string; + standardBuild: string; +} + +export interface SpecRow { + label: string; + value: string; + dynamic?: "crownPosition"; +} + +const styleADescription = "Traditional Lépine-style crown at 12 o'clock. A faithful pocket-watch orientation with the crown centered at the top of the wrist."; + +const styleBDescription = "Standard sports-watch crown at 3 o'clock. Full ergonomic wrist comfort with direct time-setting access - no strap removal needed."; + +export const colorways: RoyalPopColorway[] = [ + { + id: "ocho-negro", + name: "ONYX", + subtitle: "All Black", + style: "A", + styleText: "Style A · 12 o'clock Crown", + crownDescription: styleADescription, + crownSpec: "Style A · 12 o'clock (Lépine crown)", + crownStatValue: "12°", + crownStatLabel: "Lépine Crown", + swatchColor: "#1a1a1a", + previewBackground: "linear-gradient(180deg, #f5f5f7 0%, #eeeeef 100%)", + previewImage: "/images/royal-pop/colorways/ocho-negro.webp", + cardImage: "/images/royal-pop/gallery/ocho-negro-card.webp", + }, + { + id: "pure-white", + name: "BLANC", + subtitle: "All White", + style: "A", + styleText: "Style A · 12 o'clock Crown", + crownDescription: styleADescription, + crownSpec: "Style A · 12 o'clock (Lépine crown)", + crownStatValue: "12°", + crownStatLabel: "Lépine Crown", + swatchColor: "#e8e8e8", + previewBackground: "linear-gradient(180deg, #f7f7f8 0%, #ececef 100%)", + previewImage: "/images/royal-pop/colorways/pure-white.webp", + cardImage: "/images/royal-pop/gallery/pure-white-card.webp", + }, + { + id: "pop-pink", + name: "SAKURA", + subtitle: "Pink · Red", + style: "A", + styleText: "Style A · 12 o'clock Crown", + crownDescription: styleADescription, + crownSpec: "Style A · 12 o'clock (Lépine crown)", + crownStatValue: "12°", + crownStatLabel: "Lépine Crown", + swatchColor: "#f4b8c8", + previewBackground: "linear-gradient(180deg, #f8f1f4 0%, #f1e4eb 100%)", + previewImage: "/images/royal-pop/colorways/pop-pink.webp", + cardImage: "/images/royal-pop/gallery/pop-pink-card.webp", + }, + { + id: "racer-green", + name: "FOREST", + subtitle: "All Green", + style: "A", + styleText: "Style A · 12 o'clock Crown", + crownDescription: styleADescription, + crownSpec: "Style A · 12 o'clock (Lépine crown)", + crownStatValue: "12°", + crownStatLabel: "Lépine Crown", + swatchColor: "#2d9e48", + previewBackground: "linear-gradient(180deg, #edf5ef 0%, #e2efe5 100%)", + previewImage: "/images/royal-pop/colorways/racer-green.webp", + cardImage: "/images/royal-pop/gallery/racer-green-card.webp", + }, + { + id: "lime-blue", + name: "SAGE", + subtitle: "Mint · Sky", + style: "A", + styleText: "Style A · 12 o'clock Crown", + crownDescription: styleADescription, + crownSpec: "Style A · 12 o'clock (Lépine crown)", + crownStatValue: "12°", + crownStatLabel: "Lépine Crown", + swatchColor: "#b8e8c0", + previewBackground: "linear-gradient(180deg, #eef6f5 0%, #e3eff2 100%)", + previewImage: "/images/royal-pop/colorways/lime-blue.webp", + cardImage: "/images/royal-pop/gallery/lime-blue-card.webp", + }, + { + id: "deep-blue-orange", + name: "MIDNIGHT", + subtitle: "Navy · Orange", + style: "A", + styleText: "Style A · 12 o'clock Crown", + crownDescription: styleADescription, + crownSpec: "Style A · 12 o'clock (Lépine crown)", + crownStatValue: "12°", + crownStatLabel: "Lépine Crown", + swatchColor: "#0d1a3a", + previewBackground: "linear-gradient(180deg, #eef1f5 0%, #e4e7ee 100%)", + previewImage: "/images/royal-pop/colorways/deep-blue-orange.webp", + cardImage: "/images/royal-pop/gallery/deep-blue-orange-card.webp", + }, + { + id: "light-blue-sprint", + name: "GLACIER", + subtitle: "Steel Blue", + style: "B", + styleText: "Style B · 3 o'clock Crown", + crownDescription: styleBDescription, + crownSpec: "Style B · 3 o'clock (Right-side crown)", + crownStatValue: "3°", + crownStatLabel: "Right Crown", + swatchColor: "#3a6ea8", + previewBackground: "linear-gradient(180deg, #edf3f7 0%, #e4ebf2 100%)", + previewImage: "/images/royal-pop/colorways/light-blue-sprint.webp", + cardImage: "/images/royal-pop/gallery/light-blue-sprint-card.webp", + }, + { + id: "sorbet-pop-multi-color", + name: "SORBET", + subtitle: "Pink · Yellow", + style: "B", + styleText: "Style B · 3 o'clock Crown", + crownDescription: styleBDescription, + crownSpec: "Style B · 3 o'clock (Right-side crown)", + crownStatValue: "3°", + crownStatLabel: "Right Crown", + swatchColor: "#f5a0c0", + previewBackground: "linear-gradient(180deg, #f6f2f4 0%, #efe7eb 100%)", + previewImage: "/images/royal-pop/colorways/sorbet-pop-multi-color.webp", + cardImage: "/images/royal-pop/gallery/sorbet-pop-multi-color-card.webp", + }, +]; + +export const defaultColorway = colorways.find((colorway) => colorway.id === "sorbet-pop-multi-color") ?? colorways[0]; + +export const heroStats: StatItem[] = [ + { id: "crown-stat", value: defaultColorway.crownStatValue, label: defaultColorway.crownStatLabel }, + { value: "BIO", label: "Bioceramic Case" }, + { value: "8", label: "Colorways" }, + { id: "early-bird-stat", value: "£49.99", label: "Early Bird" }, + { value: "50m", label: "Water Resistant" }, + { value: "~1 Mo", label: "Preorder Ship" }, +]; + +export const comparisonRows: ComparisonRow[] = [ + { label: "Weight", royalPop: "Ultra-light ✓", standardBuild: "Heavier" }, + { label: "Scratch Resistance", royalPop: "Excellent ✓", standardBuild: "Moderate" }, + { label: "Skin Feel", royalPop: "Ceramic-warm ✓", standardBuild: "Plastic-cold" }, + { label: "Colour Stability", royalPop: "Permanent ✓", standardBuild: "Fades over time" }, + { label: "AP Royal Pop Match", royalPop: "Exact compound ✓", standardBuild: "Visual only" }, +]; + +export const specRows: SpecRow[] = [ + { label: "Case Material", value: "Premium Bioceramic" }, + { label: "Strap Material", value: "High-grade Silicone" }, + { label: "Crown Configuration", value: defaultColorway.crownSpec, dynamic: "crownPosition" }, + { label: "Strap Width", value: "20 MM" }, + { label: "Lug Adapter", value: "44 MM integrated" }, + { label: "Long Band", value: "126.4 MM" }, + { label: "Short Band", value: "104.2 MM" }, + { label: "Wrist Fit", value: "5.6″ – 8.5″ (14.2 – 21.6 cm)" }, + { label: "Compatibility", value: "Swatch × AP Royal Pop (all colourways)" }, + { label: "Available Colorways", value: "8 Royal Pop references" }, + { label: "Water Resistance", value: "50m" }, + { label: "Preorder Dispatch", value: "Around 1 month" }, +]; diff --git a/Store/src/layouts/BaseLayout.astro b/Store/src/layouts/BaseLayout.astro new file mode 100644 index 0000000..b21bf95 --- /dev/null +++ b/Store/src/layouts/BaseLayout.astro @@ -0,0 +1,110 @@ +--- +// Path: Store/src/layouts/BaseLayout.astro + +import "../styles/main.scss"; + +interface Props { + title?: string; + description?: string; + socialTitle?: string; + socialDescription?: string; + socialImage?: string; + noindex?: boolean; + structuredData?: Record | Array>; +} + +const siteTitle = "Royal Pop Accessory"; +const siteUrl = "https://royal-pop-accessory.com"; + +const { + title = siteTitle, + description = "Upgrade your watch with Royal Pop Accessory — bold interchangeable strap accessories in standout colourways, built to change your look in seconds.", + socialTitle, + socialDescription, + socialImage = "/images/png/pure-white.png", + noindex = false, + structuredData, +} = Astro.props; + +const documentTitle = title === siteTitle ? siteTitle : `${title} | ${siteTitle}`; +const metadataTitle = socialTitle ?? documentTitle; +const metadataDescription = socialDescription ?? description; +const canonicalUrl = new URL(Astro.url.pathname, siteUrl).toString(); +const socialImageUrl = new URL(socialImage, siteUrl).toString(); +const robotsContent = noindex ? "noindex, nofollow" : "index, follow"; +const structuredDataItems = structuredData + ? Array.isArray(structuredData) + ? structuredData + : [structuredData] + : []; +--- + + + + + + + + + + + + + + + + + + + { + structuredDataItems.map((item) => ( + + + {documentTitle} + + + + + diff --git a/Store/src/pages/buy.astro b/Store/src/pages/buy.astro new file mode 100644 index 0000000..6d076c4 --- /dev/null +++ b/Store/src/pages/buy.astro @@ -0,0 +1,896 @@ +--- +import SiteFooter from "../components/royal-pop/SiteFooter.astro"; +import { colorways, defaultColorway } from "../data/royalPop"; +import BaseLayout from "../layouts/BaseLayout.astro"; +import styles from "./buy.module.scss"; + +const apiBaseUrl = process.env.API_BASE_URL || (import.meta.env.PROD ? "" : "http://localhost:8081"); + +const styleOptions = [ + { + id: "A", + label: "Style A", + title: "12 o'clock crown", + description: "Pocket-watch inspired orientation with the crown centered at the top.", + }, + { + id: "B", + label: "Style B", + title: "3 o'clock crown", + description: "Sport-watch ergonomics with direct access to the crown on the right side.", + }, +]; + +const finishOptions = [ + { id: "silver", label: "Silver", note: "Classic brushed 316L steel" }, + { id: "black-pvd", label: "Black PVD", note: "Stealth satin hardware" }, + { id: "rose-gold", label: "Rose Gold", note: "Warm contrast finish" }, +]; + +const defaultFinish = finishOptions[0]; +const pricePerKit = 49.99; +const retailPerKit = 89.99; +const siteUrl = "https://royal-pop-accessory.com"; +const primaryImage = `${siteUrl}/images/png/pure-white.png`; + +const structuredData = { + "@context": "https://schema.org", + "@type": "Product", + name: "Royal Pop Accessory", + description: + "Royal Pop Accessory is a bold interchangeable watch strap accessory offered in standout colourways with multiple finish options and preorder configuration.", + brand: { + "@type": "Brand", + name: "Royal Pop Accessory", + }, + image: [primaryImage], + url: `${siteUrl}/buy`, + category: "Watch Accessories", + material: "Bioceramic case, silicone strap, 316L steel hardware", + additionalProperty: [ + { + "@type": "PropertyValue", + name: "Available colourways", + value: `${colorways.length}`, + }, + { + "@type": "PropertyValue", + name: "Available finishes", + value: `${finishOptions.length}`, + }, + { + "@type": "PropertyValue", + name: "Water resistance", + value: "50m", + }, + ], + offers: { + "@type": "Offer", + url: `${siteUrl}/buy`, + priceCurrency: "GBP", + price: pricePerKit.toFixed(2), + availability: "https://schema.org/PreOrder", + itemCondition: "https://schema.org/NewCondition", + }, +}; +--- + + +
+
+
+ Royal Pop + +
+ 1. Configure + 2. Details + 3. Review & Pay +
+ +

Pre-order concierge available · Ships in around 1 month

+
+
+ +
+
+
+

Royal Pop preorder

+

Choose your Royal Pop wrist conversion kit.

+
+ +
+
+ Early Bird + £49.99 before public release +
+
+ Colorways + 8 matched Royal Pop references +
+
+ Included + Bioceramic case · 50m water resistant · silicone strap +
+
+
+
+ +
+
+ + +
+
+
+

Step 1

+

Choose your crown layout.

+

Pick the wearing orientation first. Available colours update automatically.

+
+ +
+ {styleOptions.map((option) => ( + + ))} +
+
+ +
+
+

Step 2

+

Pick a colourway.

+

Each kit is matched to a specific Royal Pop reference with live availability and preorder status shown below.

+
+ +
+ {colorways.map((colorway) => ( + + ))} +
+
+ +
+
+

Step 3

+

Confirm quantity.

+
+ +
+ +
+ +
+
+ Shipping window + Pre-orders are expected to ship in around 1 month, dispatched in allocation order. +
+
+ Returns + 30-day returns after delivery, unused kits only. +
+
+ Support + Dedicated install and fitting support after purchase. +
+
+ +
+ + +
+
+
+
+ +
+
+

What ships in the box

+
    +
  • Bioceramic Royal Pop conversion shell
  • +
  • Matched silicone strap set
  • +
  • Matched Royal Pop colourway configuration
  • +
  • Fit guide and installation card
  • +
+
+
+
+ + + + + + + + +
+
diff --git a/Store/src/pages/buy.module.scss b/Store/src/pages/buy.module.scss new file mode 100644 index 0000000..5d95d26 --- /dev/null +++ b/Store/src/pages/buy.module.scss @@ -0,0 +1,1401 @@ +.buyPage { + background: #f5f5f7; + color: #1d1d1f; +} + +.topbar { + position: sticky; + top: 0; + z-index: 100; + backdrop-filter: saturate(180%) blur(20px); + background: rgba(255, 255, 255, 0.78); + border-bottom: 1px solid rgba(210, 210, 215, 0.9); +} + +.topbarInner { + width: min(calc(100% - 64px), 1200px); + margin: 0 auto; + display: grid; + grid-template-columns: 180px 1fr 260px; + align-items: center; + height: 52px; + gap: 24px; +} + +.backLink, +.helpText, +.progress span { + font-size: 12px; + line-height: 1; +} + +.backLink { + font-weight: 600; + text-decoration: none; + justify-self: start; + + &:hover { + text-decoration: none; + } +} + +.progress { + display: inline-flex; + justify-content: center; + gap: 22px; + color: #6e6e73; + + span { + position: relative; + } +} + +.progressActive { + color: #1d1d1f; + font-weight: 600; +} + +.helpText { + margin: 0; + justify-self: end; + color: #6e6e73; +} + +.hero { + padding: 72px 32px 36px; +} + +.heroInner { + width: min(100%, 1200px); + margin: 0 auto; + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr); + gap: 32px; + align-items: end; +} + +.eyebrow, +.sectionKicker, +.stepCount, +.choiceEyebrow { + margin: 0 0 12px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #6e6e73; +} + +.heroTitle { + margin: 0; + font-size: clamp(42px, 6vw, 72px); + line-height: 0.95; + letter-spacing: -0.05em; + max-width: 10ch; +} + +.heroBody { + max-width: 62ch; + margin: 22px 0 0; + font-size: 18px; + line-height: 1.55; + color: #424245; +} + +.heroMeta { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; + + div { + padding: 18px 18px 20px; + border-radius: 24px; + background: linear-gradient(180deg, #ffffff 0%, #f9f9fb 100%); + border: 1px solid #e5e5ea; + box-shadow: 0 16px 40px rgba(15, 23, 42, 0.06); + } + + strong, + span { + display: block; + } + + strong { + font-size: 16px; + margin-bottom: 6px; + } + + span { + font-size: 14px; + line-height: 1.45; + color: #6e6e73; + } +} + +.configSection { + padding: 28px 32px 64px; +} + +.configGrid { + width: min(100%, 1200px); + margin: 0 auto; + display: grid; + grid-template-columns: minmax(320px, 420px) minmax(0, 1fr); + gap: 32px; + align-items: start; +} + +.summaryRail { + position: sticky; + top: 84px; + align-self: start; +} + +.summarySticky { + display: grid; + gap: 16px; +} + +.supportRow { + width: min(100%, 1200px); + margin: 16px auto 0; +} + +.previewCard, +.orderCard, +.assuranceCard, +.stepSection, +.notesPanel { + background: #ffffff; + border: 1px solid #e5e5ea; + border-radius: 32px; + box-shadow: 0 24px 60px rgba(15, 23, 42, 0.08); +} + +.previewCard { + padding: 20px; +} + +.previewFrame { + margin-top: 8px; + padding: 22px; + border-radius: 28px; + background: radial-gradient(circle at top, #ffffff 0%, #f3f4f7 80%); + border: 1px solid #ececf1; + + img { + display: block; + width: min(100%, 248px); + max-height: 280px; + height: auto; + margin: 0 auto; + object-fit: contain; + } +} + +.previewCopy { + margin-top: 14px; + display: grid; + gap: 6px; +} + +.previewTitle { + margin: 0; + font-size: 26px; + font-weight: 700; + letter-spacing: -0.03em; +} + +.previewSubtitle { + margin: 0; + font-size: 15px; + color: #6e6e73; +} + +.previewSpec { + margin: 0; + font-size: 13px; + line-height: 1.4; + color: #424245; +} + +.orderCard { + padding: 20px; +} + +.orderHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + + h2 { + margin: 0; + font-size: 25px; + line-height: 1; + letter-spacing: -0.03em; + } + } + +.priceNow { + margin: 0; + font-size: 29px; + font-weight: 700; + letter-spacing: -0.04em; +} + +.orderRows { + margin-top: 18px; + display: grid; + gap: 12px; +} + +.orderRow, +.orderSavings { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + font-size: 14px; + + span { + color: #6e6e73; + } + + strong { + font-size: 15px; + font-weight: 600; + text-align: right; + } +} + +.orderSavings { + margin-top: 18px; + padding-top: 16px; + border-top: 1px solid #e5e5ea; +} + +.cartStatusRow { + margin-top: 18px; + padding-top: 16px; + border-top: 1px solid #e5e5ea; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + font-size: 13px; + + span { + color: #6e6e73; + } + + strong { + font-size: 14px; + font-weight: 700; + } +} + +.cartStatusCopy { + display: grid; + gap: 4px; + + strong { + display: block; + + &[data-cart-flash="true"] { + animation: cartCountPulse 0.55s ease; + } + } +} + +.cartUtilityButton { + padding: 0; + border: 0; + background: transparent; + color: #0071e3; + font-size: 13px; + font-weight: 600; + white-space: nowrap; + cursor: pointer; + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } +} + +.cartList { + margin-top: 16px; + display: grid; + gap: 12px; + transition: transform 180ms ease, opacity 180ms ease; + + &[data-cart-flash="true"] { + animation: cartListFlash 0.55s ease; + } +} + +.cartItem { + display: grid; + grid-template-columns: 92px minmax(0, 1fr); + gap: 12px; + align-items: center; + padding: 10px; + border: 1px solid #e5e5ea; + border-radius: 22px; + background: linear-gradient(180deg, #ffffff 0%, #fafafd 100%); + box-shadow: 0 12px 28px rgba(15, 23, 42, 0.05); +} + +.cartItemImageWrap { + padding: 10px; + border-radius: 18px; + min-height: 86px; + display: grid; + place-items: center; + border: 1px solid rgba(210, 210, 215, 0.72); + + img { + display: block; + width: 100%; + height: auto; + max-width: 72px; + } +} + +.cartItemBody { + display: grid; + gap: 4px; + min-width: 0; +} + +.cartItemTop { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} + +.cartItemTitle, +.cartItemSubtitle, +.cartItemMeta, +.cartItemPrice { + margin: 0; +} + +.cartItemTitle { + font-size: 15px; + line-height: 1.05; + font-weight: 700; + letter-spacing: -0.02em; +} + +.cartItemSubtitle { + font-size: 12px; + line-height: 1.35; + color: #424245; +} + +.cartItemMeta { + font-size: 11px; + line-height: 1.35; + color: #6e6e73; +} + +.cartItemFooter { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: 6px; +} + +.cartItemPrice { + font-size: 13px; + font-weight: 700; + color: #1d1d1f; +} + +.cartItemRemove { + flex: 0 0 auto; + width: 26px; + height: 26px; + padding: 0; + border: 0; + border-radius: 999px; + background: rgba(0, 0, 0, 0.05); + color: #6e6e73; + font-size: 18px; + line-height: 1; + cursor: pointer; + transition: background 180ms ease, color 180ms ease; + + &:hover { + background: rgba(180, 35, 24, 0.1); + color: #b42318; + } + + &:focus-visible { + outline: 2px solid #0071e3; + outline-offset: 2px; + } +} + +.cartItemEmpty { + padding: 14px 16px; + border: 1px dashed #d2d2d7; + border-radius: 20px; + background: rgba(255, 255, 255, 0.72); + font-size: 12px; + line-height: 1.45; + color: #6e6e73; +} + +.summaryActions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-top: 18px; + + .primaryCta, + .secondaryCta { + margin-top: 0; + } +} + +.primaryCta { + width: 100%; + padding: 14px 18px; + border: 0; + border-radius: 999px; + background: #0071e3; + color: #ffffff; + font-size: 14px; + font-weight: 600; + cursor: pointer; + + &:hover { + background: #0077ed; + } + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + background: #0071e3; + } + + &[data-added="true"] { + animation: addButtonPulse 0.8s ease; + } +} + +.secondaryCta { + width: 100%; + padding: 14px 18px; + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 999px; + background: rgba(0, 0, 0, 0.04); + color: #1d1d1f; + font-size: 14px; + font-weight: 600; + cursor: pointer; + + &:hover:not(:disabled) { + background: rgba(0, 0, 0, 0.06); + } + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } +} + +.orderFine { + margin: 12px 0 0; + font-size: 12px; + line-height: 1.4; + color: #6e6e73; + + &[data-cart-feedback-state="success"] { + color: #18794e; + } + + &[data-cart-feedback-state="error"] { + color: #b42318; + } +} + +.cartToast { + position: fixed; + right: 24px; + bottom: 24px; + z-index: 120; + min-width: min(320px, calc(100vw - 32px)); + max-width: 360px; + padding: 14px 16px; + border-radius: 22px; + border: 1px solid rgba(210, 210, 215, 0.9); + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 20px 48px rgba(15, 23, 42, 0.16); + backdrop-filter: saturate(180%) blur(18px); + opacity: 0; + transform: translateY(16px); + pointer-events: none; + transition: opacity 180ms ease, transform 180ms ease; + + strong, + span { + display: block; + margin: 0; + } + + strong { + font-size: 14px; + margin-bottom: 4px; + } + + span { + font-size: 12px; + line-height: 1.4; + color: #424245; + } + + &[data-visible="true"] { + opacity: 1; + transform: translateY(0); + } + + &[data-state="success"] { + border-color: rgba(24, 121, 78, 0.18); + } + + &[data-state="error"] { + border-color: rgba(180, 35, 24, 0.18); + + strong { + color: #b42318; + } + } +} + +@keyframes addButtonPulse { + 0% { + transform: scale(1); + } + 40% { + transform: scale(1.03); + } + 100% { + transform: scale(1); + } +} + +@keyframes cartListFlash { + 0% { + transform: translateY(0); + } + 35% { + transform: translateY(-2px); + } + 100% { + transform: translateY(0); + } +} + +@keyframes cartCountPulse { + 0% { + transform: scale(1); + } + 40% { + transform: scale(1.04); + } + 100% { + transform: scale(1); + } +} + +.assuranceCard { + padding: 20px; + margin-top: 16px; + + h3 { + margin: 0 0 12px; + font-size: 18px; + letter-spacing: -0.02em; + } + + ul { + margin: 0; + padding-left: 18px; + display: grid; + gap: 8px; + color: #424245; + font-size: 13px; + line-height: 1.4; + } +} + +@media (max-height: 920px) and (min-width: 901px) { + .summaryRail { + top: 72px; + } + + .summarySticky { + gap: 12px; + } + + .previewCard, + .orderCard, + .assuranceCard { + padding: 14px; + border-radius: 26px; + } + + .previewFrame { + padding: 14px; + + img { + width: min(100%, 148px); + max-height: 14dvh; + } + } + + .previewCopy { + margin-top: 10px; + gap: 4px; + } + + .previewTitle { + font-size: 20px; + } + + .previewSubtitle { + font-size: 12px; + } + + .previewSpec, + .orderRow, + .orderSavings { + font-size: 11px; + } + + .orderHeader { + h2 { + font-size: 20px; + } + } + + .priceNow { + font-size: 22px; + } + + .orderRows { + margin-top: 12px; + gap: 8px; + } + + .orderSavings { + margin-top: 12px; + padding-top: 10px; + } + + .primaryCta { + margin-top: 12px; + padding: 11px 16px; + font-size: 12px; + } + + .secondaryCta { + padding: 11px 16px; + font-size: 12px; + } + + .assuranceCard ul { + font-size: 12px; + line-height: 1.35; + } + + .assuranceCard h3 { + font-size: 16px; + margin-bottom: 10px; + } +} + +.builderColumn { + display: grid; + gap: 24px; +} + +.stepSection { + padding: 28px; + scroll-margin-top: 96px; +} + +.stepHeader { + margin-bottom: 22px; + + h2 { + margin: 0; + font-size: 34px; + line-height: 1.02; + letter-spacing: -0.04em; + } + + p:last-child { + margin: 12px 0 0; + font-size: 16px; + line-height: 1.55; + color: #6e6e73; + } +} + +.choiceGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.choiceCard, +.colorCard, +.quantityButton { + appearance: none; + -webkit-appearance: none; + width: 100%; + padding: 0; + border: 1px solid #d2d2d7; + border-radius: 28px; + background: #ffffff; + color: inherit; + text-align: left; + cursor: pointer; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease, + transform 0.2s ease; + + &:hover { + border-color: #86868b; + box-shadow: 0 16px 32px rgba(15, 23, 42, 0.08); + transform: translateY(-1px); + } + + &[aria-pressed="true"] { + border-color: #0071e3; + box-shadow: 0 0 0 4px rgba(0, 113, 227, 0.15); + } + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + box-shadow: none; + transform: none; + } + + &:focus-visible { + outline: 2px solid #0071e3; + outline-offset: 3px; + } +} + +.choiceCard { + padding: 20px 22px 22px; + display: grid; + gap: 8px; + + strong { + font-size: 24px; + line-height: 1.05; + letter-spacing: -0.03em; + } + + span { + font-size: 14px; + line-height: 1.5; + color: #6e6e73; + } +} + +.colorGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; +} + +.colorCard { + overflow: hidden; + + &[disabled] { + display: none; + } + + &[data-inventory-state="soldout"] { + opacity: 0.62; + } + + &[data-inventory-state="soldout"][aria-pressed="true"] { + border-color: rgba(179, 38, 30, 0.5); + box-shadow: 0 20px 48px rgba(179, 38, 30, 0.08); + } +} + +.colorImageWrap { + padding: 26px; + border-bottom: 1px solid rgba(210, 210, 215, 0.72); + + img { + display: block; + width: 100%; + height: auto; + } +} + +.colorCopy { + padding: 18px 20px 20px; + display: grid; + gap: 8px; + + p, + strong, + span { + margin: 0; + } + + strong { + font-size: 24px; + line-height: 1.05; + letter-spacing: -0.03em; + } + + span { + font-size: 14px; + line-height: 1.5; + color: #6e6e73; + } +} + +.availabilityLine { + margin: 4px 0 0; +} + +.availabilityBadge { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 26px; + padding: 0 12px; + border-radius: 999px; + border: 1px solid rgba(210, 210, 215, 0.92); + background: rgba(245, 245, 247, 0.92); + color: #424245; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + line-height: 1; + + &[data-state="ready"] { + background: rgba(232, 245, 235, 0.95); + border-color: rgba(76, 175, 80, 0.22); + color: #1f6f3e; + } + + &[data-state="low"] { + background: rgba(255, 244, 229, 0.95); + border-color: rgba(255, 167, 38, 0.28); + color: #a15c00; + } + + &[data-state="soldout"] { + background: rgba(255, 235, 238, 0.95); + border-color: rgba(211, 47, 47, 0.18); + color: #b3261e; + } +} + +.colorSwatchRow { + display: inline-flex; + align-items: center; + gap: 10px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #6e6e73; +} + +.colorSwatch { + width: 14px; + height: 14px; + border-radius: 999px; + border: 1px solid rgba(0, 0, 0, 0.08); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.25); +} + +.quantityRow { + display: grid; + grid-template-columns: minmax(0, 320px); + gap: 14px; +} + +.quantityField { + padding: 20px; + border: 1px solid rgba(210, 210, 215, 0.82); + border-radius: 24px; + background: rgba(255, 255, 255, 0.92); + box-shadow: 0 16px 36px rgba(15, 23, 42, 0.06); +} + +.quantityField { + display: grid; + gap: 10px; + + span { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #6e6e73; + } + + input { + width: 100%; + padding: 16px 18px; + border: 1px solid rgba(29, 29, 31, 0.14); + border-radius: 18px; + background: #ffffff; + font-size: 24px; + font-weight: 700; + letter-spacing: -0.03em; + color: #1d1d1f; + + &:focus-visible { + outline: 2px solid rgba(0, 113, 227, 0.35); + outline-offset: 2px; + border-color: rgba(0, 113, 227, 0.5); + } + } +} + +.notesPanel { + margin-top: 18px; + padding: 20px 22px; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + + div { + display: grid; + gap: 6px; + } + + strong { + font-size: 14px; + } + + span { + font-size: 13px; + line-height: 1.5; + color: #6e6e73; + } +} + +.bottomCheckoutCta { + margin-top: 20px; +} + +@media (max-width: 900px) { + .topbarInner, + .heroInner, + .configGrid { + width: min(calc(100% - 48px), 1200px); + } + + .heroInner { + grid-template-columns: 1fr; + } + + .heroMeta { + grid-template-columns: 1fr; + } + + .configGrid { + grid-template-columns: 1fr; + } + + .summaryRail { + position: static; + } +} + +@media (max-width: 734px) { + .topbar { + position: sticky; + top: 0; + } + + .topbarInner { + width: calc(100% - 32px); + height: auto; + padding: calc(env(safe-area-inset-top, 0px) + 10px) 0 10px; + grid-template-columns: auto auto; + justify-content: space-between; + gap: 10px 16px; + } + + .helpText { + display: none; + } + + .progress { + grid-column: 1 / -1; + justify-content: flex-start; + gap: 10px; + overflow-x: auto; + padding-bottom: 2px; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + + span { + flex: 0 0 auto; + padding: 7px 10px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.04); + } + } + + .progressActive { + background: rgba(0, 113, 227, 0.1); + color: #0071e3; + } + + .hero { + padding: 36px 16px 20px; + } + + .heroInner { + width: 100%; + gap: 18px; + align-items: start; + } + + .eyebrow { + margin-bottom: 10px; + } + + .heroTitle { + font-size: clamp(34px, 11vw, 46px); + line-height: 0.98; + max-width: none; + } + + .heroBody { + margin-top: 14px; + font-size: 15px; + line-height: 1.45; + max-width: none; + } + + .heroMeta { + gap: 10px; + + div { + padding: 14px 14px 15px; + border-radius: 20px; + box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06); + } + + strong { + font-size: 14px; + margin-bottom: 4px; + } + + span { + font-size: 12px; + line-height: 1.4; + } + } + + .configSection { + padding: 12px 16px 40px; + } + + .configGrid, + .supportRow { + width: 100%; + } + + .configGrid { + gap: 16px; + } + + .builderColumn { + order: 1; + } + + .summaryRail { + order: 2; + } + + .summarySticky, + .builderColumn { + gap: 16px; + } + + .previewCard, + .orderCard, + .assuranceCard, + .stepSection, + .notesPanel { + border-radius: 24px; + box-shadow: 0 14px 30px rgba(15, 23, 42, 0.06); + } + + .previewCard, + .orderCard, + .assuranceCard, + .stepSection { + padding: 18px; + } + + .previewFrame { + padding: 16px; + border-radius: 22px; + + img { + width: min(100%, 220px); + max-height: 220px; + } + } + + .previewCopy { + margin-top: 12px; + gap: 4px; + } + + .previewTitle { + font-size: 22px; + } + + .previewSubtitle { + font-size: 13px; + } + + .previewSpec { + font-size: 12px; + } + + .orderHeader { + align-items: flex-start; + + h2 { + font-size: 22px; + } + } + + .priceNow { + font-size: 24px; + } + + .orderRows { + margin-top: 14px; + gap: 10px; + } + + .orderRow, + .orderSavings { + font-size: 12px; + gap: 12px; + + strong { + font-size: 13px; + } + } + + .cartItem { + grid-template-columns: 78px minmax(0, 1fr); + gap: 10px; + padding: 8px; + border-radius: 18px; + } + + .cartItemImageWrap { + min-height: 72px; + padding: 8px; + border-radius: 14px; + + img { + max-width: 60px; + } + } + + .cartItemTitle { + font-size: 14px; + } + + .cartItemSubtitle, + .cartItemMeta, + .cartItemPrice { + font-size: 11px; + } + + .primaryCta { + padding: 13px 16px; + font-size: 13px; + } + + .orderFine { + font-size: 11px; + } + + .cartUtilityButton { + font-size: 12px; + } + + .stepSection { + scroll-margin-top: 88px; + } + + .stepHeader { + margin-bottom: 16px; + + h2 { + font-size: 26px; + line-height: 1.06; + } + + p:last-child { + margin-top: 10px; + font-size: 14px; + line-height: 1.45; + } + } + + .choiceGrid, + .colorGrid { + grid-template-columns: 1fr; + gap: 12px; + } + + .choiceCard { + padding: 16px; + gap: 6px; + + strong { + font-size: 20px; + } + + span { + font-size: 13px; + line-height: 1.4; + } + } + + .colorImageWrap { + padding: 18px; + } + + .colorCopy { + padding: 14px 16px 16px; + gap: 6px; + + strong { + font-size: 20px; + } + + span { + font-size: 12px; + line-height: 1.4; + } + } + + .colorSwatchRow { + gap: 8px; + font-size: 11px; + } + + .quantityRow { + grid-template-columns: 1fr; + gap: 10px; + } + + .quantityField { + padding: 16px; + } + + .quantityField { + input { + font-size: 21px; + } + } + + .notesPanel { + margin-top: 14px; + padding: 16px; + grid-template-columns: 1fr; + gap: 12px; + + strong { + font-size: 13px; + } + + span { + font-size: 12px; + line-height: 1.45; + } + } + + .summaryActions { + grid-template-columns: 1fr; + } + + .cartToast { + left: 16px; + right: 16px; + bottom: 16px; + min-width: 0; + max-width: none; + } + + .cartStatusRow { + align-items: center; + } + + .cartUtilityButton { + font-size: 12px; + } + + .bottomCheckoutCta { + margin-top: 14px; + } + + .supportRow { + margin-top: 12px; + } + + .assuranceCard { + margin-top: 0; + + h3 { + font-size: 16px; + margin-bottom: 10px; + } + + ul { + gap: 6px; + font-size: 12px; + line-height: 1.4; + } + } + + .choiceCard, + .colorCard, + .quantityButton { + &:hover { + transform: none; + box-shadow: none; + } + } +} diff --git a/Store/src/pages/checkout.astro b/Store/src/pages/checkout.astro new file mode 100644 index 0000000..c23a227 --- /dev/null +++ b/Store/src/pages/checkout.astro @@ -0,0 +1,877 @@ +--- +import SiteFooter from "../components/royal-pop/SiteFooter.astro"; +import { colorways, defaultColorway } from "../data/royalPop"; +import BaseLayout from "../layouts/BaseLayout.astro"; +import styles from "./checkout.module.scss"; + +const finishOptions = [ + { id: "silver", label: "Silver", note: "Classic brushed 316L steel" }, + { id: "black-pvd", label: "Black PVD", note: "Stealth satin hardware" }, + { id: "rose-gold", label: "Rose Gold", note: "Warm contrast finish" }, +]; + +const pricePerKit = 49.99; +const retailPerKit = 89.99; +const defaultFinish = finishOptions[0]; +const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? ""; +const stripePriceId = process.env.STRIPE_PRICE_ID ?? "price_1TkPnvCpoCwKMSycHiQBPVms"; +const apiBaseUrl = process.env.API_BASE_URL || (import.meta.env.PROD ? "" : "http://localhost:8081"); +const detailsStorageKey = "royal-pop-checkout-details"; +--- + + +
+
+
+ Back to details + +
+ 1. Configure + 2. Details + 3. Review & Pay +
+ +

Final review & payment

+
+
+ +
+
+
+

Review & Pay

+

Review your configuration and complete payment.

+

Review your selected kit, check the total, and choose how you want to complete your preorder.

+
+ +
+
+ Review stage + Check your selected kit, pricing, and preorder summary before submission. +
+
+ Payment next + Choose the payment route that fits you best before you place the request. +
+
+ Everything in one place + Your configuration, details, and final total stay visible here for one last check. +
+
+
+
+ +
+
+ + +
+
+
+

Step 3A

+

Payment method

+

Choose how you would like to pay and complete your preorder securely with Stripe.

+
+ +
+
+
+
+ Card payment + Use the secure payment form below to enter your card or any other supported payment method available for your region. +
+

Secure

+
+ +
+
+ +
+

Loading secure card form…

+

Preparing your secure payment form. This usually takes a moment.

+
+ +
+ + +
+ +
+
+ Accepted payment options + Your available cards and payment methods will appear automatically based on your device and location. +
+
+ Secure authentication + Billing fields and any required verification steps are handled securely during checkout. +
+
+
+ +
+
+ Stripe status +

Initializing

+ Stripe is preparing a secure checkout surface for this payment method. +
+
+ Secure mount point + No payment errors. Stripe validation and mount feedback will appear here if anything blocks checkout. + +
+
+
+
+ +
+
+

Step 3B

+

Review your preorder

+

Use this step to confirm the product build and pricing before the final submit state.

+
+ +
+
+
+ Selected kits + {defaultColorway.name} +
+

1 kit

+
+ +
+
+ Configuration summary + {defaultColorway.styleText} +
+

Updating…

+
+
+
+ +
+
+

Final review

+

Confirm your preorder request

+

Confirm the essentials below before you send your preorder request.

+
+ +
+ + + +
+ +
+ Back to details + +
+ +

Please confirm that you have reviewed your configuration, quantity, and total before completing payment.

+
+
+
+
+ + + + + +
+
diff --git a/Store/src/pages/checkout.module.scss b/Store/src/pages/checkout.module.scss new file mode 100644 index 0000000..47b4eb7 --- /dev/null +++ b/Store/src/pages/checkout.module.scss @@ -0,0 +1,1178 @@ +.checkoutPage { + background: #f5f5f7; + color: #1d1d1f; + min-height: 100vh; +} + +.topbar { + position: sticky; + top: 0; + z-index: 100; + backdrop-filter: saturate(180%) blur(20px); + background: rgba(255, 255, 255, 0.78); + border-bottom: 1px solid rgba(210, 210, 215, 0.9); +} + +.topbarInner { + width: min(calc(100% - 64px), 1200px); + margin: 0 auto; + display: grid; + grid-template-columns: 180px 1fr 280px; + align-items: center; + height: 52px; + gap: 24px; +} + +.backLink, +.helpText, +.progress span { + font-size: 12px; + line-height: 1; +} + +.backLink { + font-weight: 600; + text-decoration: none; + justify-self: start; + + &:hover { + text-decoration: none; + } +} + +.progress { + display: inline-flex; + justify-content: center; + gap: 22px; + color: #6e6e73; +} + +.progressActive { + color: #1d1d1f; + font-weight: 600; +} + +.helpText { + margin: 0; + justify-self: end; + color: #6e6e73; +} + +.hero { + padding: 72px 32px 36px; +} + +.heroInner { + width: min(100%, 1200px); + margin: 0 auto; + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr); + gap: 32px; + align-items: end; +} + +.eyebrow, +.sectionKicker, +.stepCount { + margin: 0 0 12px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #6e6e73; +} + +.heroTitle { + margin: 0; + font-size: clamp(42px, 6vw, 72px); + line-height: 0.95; + letter-spacing: -0.05em; + max-width: 11ch; +} + +.heroBody { + max-width: 60ch; + margin: 18px 0 0; + font-size: 17px; + line-height: 1.55; + color: #424245; +} + +.heroMeta { + display: grid; + grid-template-columns: 1fr; + gap: 14px; + + div { + padding: 18px 18px 20px; + border-radius: 24px; + background: linear-gradient(180deg, #ffffff 0%, #f9f9fb 100%); + border: 1px solid #e5e5ea; + box-shadow: 0 16px 40px rgba(15, 23, 42, 0.06); + } + + strong, + span { + display: block; + } + + strong { + font-size: 16px; + margin-bottom: 6px; + } + + span { + font-size: 14px; + line-height: 1.45; + color: #6e6e73; + } +} + +.checkoutSection { + padding: 28px 32px 64px; +} + +.checkoutGrid { + width: min(100%, 1200px); + margin: 0 auto; + display: grid; + grid-template-columns: minmax(320px, 400px) minmax(0, 1fr); + gap: 32px; + align-items: start; +} + +.summaryRail { + position: sticky; + top: 84px; + align-self: start; +} + +.summarySticky { + display: grid; + gap: 16px; +} + +.previewCard, +.orderCard, +.assuranceCard, +.panel, +.reviewCard { + background: #ffffff; + border: 1px solid #e5e5ea; + border-radius: 32px; + box-shadow: 0 24px 60px rgba(15, 23, 42, 0.08); +} + +.previewCard, +.orderCard, +.assuranceCard, +.panel { + padding: 22px; +} + +.previewFrame { + margin-top: 8px; + padding: 22px; + border-radius: 28px; + background: radial-gradient(circle at top, #ffffff 0%, #f3f4f7 80%); + border: 1px solid #ececf1; + + img { + display: block; + width: min(100%, 248px); + height: auto; + margin: 0 auto; + object-fit: contain; + } +} + +.previewCopy { + margin-top: 14px; + display: grid; + gap: 6px; +} + +.previewTitle { + margin: 0; + font-size: 26px; + font-weight: 700; + letter-spacing: -0.03em; +} + +.previewSubtitle { + margin: 0; + font-size: 15px; + color: #6e6e73; +} + +.previewSpec { + margin: 0; + font-size: 13px; + line-height: 1.4; + color: #424245; +} + +.orderHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + + h2 { + margin: 0; + font-size: 25px; + line-height: 1; + letter-spacing: -0.03em; + } +} + +.priceNow { + margin: 0; + font-size: 29px; + font-weight: 700; + letter-spacing: -0.04em; +} + +.orderRows { + margin-top: 18px; + display: grid; + gap: 12px; +} + +.cartList { + margin-top: 18px; + display: grid; + gap: 14px; +} + +.orderRow, +.orderSavings { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + font-size: 14px; + + span { + color: #6e6e73; + } + + strong { + font-size: 15px; + font-weight: 600; + text-align: right; + } +} + +.orderSavings { + margin-top: 18px; + padding-top: 16px; + border-top: 1px solid #e5e5ea; +} + +.cartLine { + display: grid; + gap: 4px; + min-width: 0; + + span { + font-weight: 600; + color: #1d1d1f; + } +} + +.cartLineMeta { + margin: 0; + font-size: 12px; + line-height: 1.35; + color: #6e6e73; +} + +.cartItem { + overflow: hidden; + border: 1px solid #e5e5ea; + border-radius: 24px; + background: linear-gradient(180deg, #ffffff 0%, #fafafd 100%); +} + +.cartItemImageWrap { + padding: 18px; + border-bottom: 1px solid rgba(210, 210, 215, 0.72); + + img { + display: block; + width: 100%; + height: auto; + max-width: 220px; + margin: 0 auto; + } +} + +.cartItemBody { + padding: 16px 18px 18px; + display: grid; + gap: 14px; +} + +.cartItemTitle, +.cartItemSubtitle, +.cartItemMeta, +.cartItemPrice { + margin: 0; +} + +.cartItemTitle { + display: block; + font-size: 18px; + line-height: 1.05; + letter-spacing: -0.03em; +} + +.cartItemSubtitle { + font-size: 13px; + line-height: 1.4; + color: #424245; +} + +.cartItemMeta { + font-size: 12px; + line-height: 1.4; + color: #6e6e73; + + & + & { + margin-top: 4px; + } +} + +.cartItemFooter { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.cartItemPrice { + font-size: 15px; + font-weight: 700; +} + +.cartItemRemove { + padding: 0; + border: 0; + background: transparent; + color: #0071e3; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.cartItemEmpty { + padding: 18px; + border: 1px dashed #d2d2d7; + border-radius: 22px; + background: rgba(255, 255, 255, 0.7); + font-size: 13px; + line-height: 1.5; + color: #6e6e73; +} + +.assuranceCard { + h3 { + margin: 0 0 12px; + font-size: 18px; + letter-spacing: -0.02em; + } + + ul { + margin: 0; + padding-left: 18px; + display: grid; + gap: 8px; + color: #424245; + font-size: 13px; + line-height: 1.45; + } +} + +.formColumn { + display: grid; + gap: 24px; +} + +.panelHeader { + margin-bottom: 22px; + + h2 { + margin: 0; + font-size: 34px; + line-height: 1.02; + letter-spacing: -0.04em; + } + + p:last-child { + margin: 12px 0 0; + font-size: 16px; + line-height: 1.55; + color: #6e6e73; + } +} + +.fieldGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.field { + display: grid; + gap: 8px; + + span { + font-size: 13px; + font-weight: 600; + color: #424245; + + em { + font-style: normal; + font-weight: 500; + color: #6e6e73; + } + } + + input, + select, + textarea { + width: 100%; + padding: 14px 16px; + border: 1px solid #d2d2d7; + border-radius: 18px; + background: #fbfbfd; + font-size: 14px; + color: #1d1d1f; + box-sizing: border-box; + + &:focus { + outline: 2px solid rgba(0, 113, 227, 0.25); + outline-offset: 0; + border-color: #0071e3; + } + } + + textarea { + min-height: 112px; + resize: vertical; + } +} + +.fieldFull { + grid-column: 1 / -1; +} + +.stack { + display: grid; + gap: 14px; +} + +.paymentShell { + display: grid; + gap: 18px; + + &[data-payment-state="loading"] { + .stripeMount { + border-color: #bcd0fb; + background: linear-gradient(180deg, #f8fbff 0%, #f2f6fd 100%); + } + } + + &[data-payment-state="error"] { + .stripeMount { + border-color: rgba(180, 35, 24, 0.28); + background: linear-gradient(180deg, #fff8f7 0%, #fff3f1 100%); + } + + .paymentStatusPill { + background: rgba(180, 35, 24, 0.12); + color: #b42318; + } + } + + &[data-payment-state="ready"] { + .paymentStatusPill { + background: rgba(24, 121, 78, 0.12); + color: #18794e; + } + } +} + +.paymentModeRow { + display: inline-grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + padding: 6px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.04); + width: min(100%, 420px); +} + +.paymentModeButton { + padding: 12px 16px; + border: 0; + border-radius: 999px; + background: transparent; + color: #6e6e73; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: + background-color 160ms ease, + color 160ms ease, + box-shadow 160ms ease; + + &[aria-pressed="true"] { + background: #ffffff; + color: #1d1d1f; + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08); + } + + &:focus-visible { + outline: 2px solid rgba(0, 113, 227, 0.35); + outline-offset: 2px; + } +} + +.paymentPanel { + padding: 22px; + border: 1px solid #e5e5ea; + border-radius: 28px; + background: linear-gradient(180deg, #ffffff 0%, #fafafd 100%); + box-shadow: 0 18px 44px rgba(15, 23, 42, 0.06); +} + +.paymentPanelHeader { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + margin-bottom: 16px; + + strong, + span, + p { + margin: 0; + } + + strong { + display: block; + font-size: 18px; + margin-bottom: 6px; + } + + span, + p { + font-size: 13px; + line-height: 1.45; + color: #6e6e73; + } + + p { + white-space: nowrap; + font-weight: 600; + color: #1d1d1f; + } +} + +.stripeMount { + padding: 20px; + border: 1px dashed #c7d7f8; + border-radius: 24px; + background: linear-gradient(180deg, #f7fbff 0%, #f3f7fd 100%); + min-height: 180px; + display: grid; + align-content: center; + gap: 8px; +} + +.stripeLoadingState, +.stripeReadyState { + display: grid; + gap: 12px; +} + +.stripeLoadingState { + grid-template-columns: auto 1fr; + align-items: start; + + &[hidden] { + display: none; + } +} + +.stripeReadyState { + align-content: center; + + &[hidden] { + display: none; + } +} + +.stripeSpinner { + width: 20px; + height: 20px; + border-radius: 999px; + border: 2px solid rgba(0, 113, 227, 0.16); + border-top-color: #0071e3; + animation: stripeSpin 0.9s linear infinite; + margin-top: 2px; +} + +.stripeLoadingCopy { + display: grid; + gap: 6px; +} + +.stripeSkeletonGroup { + grid-column: 1 / -1; + display: grid; + gap: 8px; + margin-top: 4px; +} + +.stripeSkeletonLine { + display: block; + height: 12px; + border-radius: 999px; + background: linear-gradient(90deg, rgba(0, 113, 227, 0.08) 0%, rgba(0, 113, 227, 0.18) 50%, rgba(0, 113, 227, 0.08) 100%); + background-size: 220% 100%; + animation: stripeShimmer 1.4s ease-in-out infinite; + + &:nth-child(1) { + width: 72%; + } + + &:nth-child(2) { + width: 88%; + } + + &:nth-child(3) { + width: 56%; + } +} + +.stripeMountLabel, +.stripeMountBody { + margin: 0; +} + +.stripeMountLabel { + font-size: 13px; + font-weight: 700; + letter-spacing: -0.01em; + color: #1d1d1f; +} + +.stripeMountBody { + font-size: 14px; + line-height: 1.5; + color: #4a4a50; + max-width: 52ch; +} + +.paymentMetaRow, +.paymentStatusRow { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-top: 14px; +} + +.paymentMetaCard, +.paymentStatusCard { + padding: 16px 18px; + border-radius: 22px; + border: 1px solid #e5e5ea; + background: rgba(255, 255, 255, 0.82); + + strong, + span { + display: block; + } + + strong { + font-size: 14px; + margin-bottom: 6px; + } + + span { + font-size: 13px; + line-height: 1.45; + color: #6e6e73; + } +} + +.paymentStatusPill { + display: inline-flex; + align-items: center; + justify-content: center; + width: fit-content; + margin: 8px 0 10px; + padding: 6px 10px; + border-radius: 999px; + background: rgba(0, 113, 227, 0.12); + color: #0071e3; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.01em; +} + +.paymentRetryButton { + margin-top: 12px; + padding: 0; + border: 0; + background: transparent; + color: #0071e3; + font-size: 13px; + font-weight: 600; + cursor: pointer; + width: fit-content; + + &[hidden] { + display: none; + } + + &:focus-visible { + outline: 2px solid rgba(0, 113, 227, 0.35); + outline-offset: 2px; + border-radius: 6px; + } + + &:hover { + text-decoration: underline; + } +} + +@keyframes stripeSpin { + to { + transform: rotate(360deg); + } +} + +@keyframes stripeShimmer { + 0% { + background-position: 100% 0; + } + + 100% { + background-position: -100% 0; + } +} + +.optionCard, +.reviewCard { + padding: 18px 20px; + border-radius: 24px; + background: linear-gradient(180deg, #ffffff 0%, #fafafd 100%); +} + +.optionCard { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; + + strong, + span, + p { + margin: 0; + } + + strong { + display: block; + font-size: 16px; + margin-bottom: 6px; + } + + span, + p { + font-size: 13px; + line-height: 1.45; + color: #6e6e73; + } + + p { + white-space: nowrap; + font-weight: 600; + color: #1d1d1f; + } +} + +.checkboxRow { + display: grid; + grid-template-columns: 18px 1fr; + gap: 12px; + align-items: start; + font-size: 14px; + line-height: 1.5; + color: #424245; + + & + & { + margin-top: 14px; + } + + input { + margin: 3px 0 0; + } + + span { + font-size: 14px; + } +} + +.actions { + display: flex; + gap: 12px; + margin-top: 20px; + flex-wrap: wrap; +} + +.primaryCta, +.secondaryCta { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 14px 18px; + border-radius: 999px; + font-size: 14px; + font-weight: 600; + text-decoration: none; + cursor: pointer; + border: 0; +} + +.primaryCta { + background: #0071e3; + color: #ffffff; + + &:hover { + background: #0077ed; + text-decoration: none; + } + + &:disabled, + &[aria-disabled="true"] { + opacity: 0.45; + cursor: not-allowed; + pointer-events: none; + } +} + +.secondaryCta { + background: rgba(0, 0, 0, 0.04); + color: #1d1d1f; + + &:hover { + text-decoration: none; + background: rgba(0, 0, 0, 0.06); + } + + &:disabled, + &[aria-disabled="true"] { + opacity: 0.45; + cursor: not-allowed; + pointer-events: none; + } +} + +.footnote { + margin: 14px 0 0; + font-size: 12px; + line-height: 1.45; + color: #6e6e73; +} + +.statusCard { + padding: 22px; + border-radius: 28px; + background: linear-gradient(180deg, #ffffff 0%, #f7fbff 100%); + border: 1px solid rgba(0, 113, 227, 0.14); + box-shadow: 0 20px 50px rgba(0, 113, 227, 0.08); +} + +.statusPill { + display: inline-flex; + align-items: center; + justify-content: center; + margin: 0; + padding: 7px 12px; + border-radius: 999px; + background: rgba(0, 113, 227, 0.1); + color: #0071e3; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; +} + +.statusTitle { + margin: 16px 0 0; + font-size: 28px; + line-height: 1.05; + letter-spacing: -0.03em; +} + +.statusBody { + margin: 12px 0 0; + font-size: 15px; + line-height: 1.55; + color: #424245; +} + +.inlineMeta { + margin-top: 20px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + + div { + padding: 16px 18px; + border-radius: 22px; + background: rgba(255, 255, 255, 0.88); + border: 1px solid rgba(210, 210, 215, 0.75); + } + + span, + strong { + display: block; + margin: 0; + } + + span { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #6e6e73; + } + + strong { + margin-top: 6px; + font-size: 15px; + line-height: 1.35; + color: #1d1d1f; + } +} + +@media (max-width: 900px) { + .topbarInner, + .heroInner, + .checkoutGrid { + width: min(calc(100% - 48px), 1200px); + } + + .heroInner, + .checkoutGrid, + .fieldGrid { + grid-template-columns: 1fr; + } + + .summaryRail { + position: static; + } + + .paymentMetaRow, + .paymentStatusRow { + grid-template-columns: 1fr; + } +} + +@media (max-width: 734px) { + .topbarInner { + width: calc(100% - 32px); + height: auto; + padding: calc(env(safe-area-inset-top, 0px) + 10px) 0 10px; + grid-template-columns: auto; + gap: 10px; + } + + .helpText { + display: none; + } + + .progress { + justify-content: flex-start; + gap: 10px; + overflow-x: auto; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + + span { + flex: 0 0 auto; + padding: 7px 10px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.04); + } + } + + .progressActive { + background: rgba(0, 113, 227, 0.1); + color: #0071e3; + } + + .hero { + padding: 36px 16px 20px; + } + + .heroInner, + .checkoutGrid { + width: 100%; + gap: 16px; + } + + .heroTitle { + font-size: clamp(34px, 11vw, 46px); + max-width: none; + } + + .heroBody { + font-size: 15px; + line-height: 1.45; + } + + .heroMeta div, + .previewCard, + .orderCard, + .assuranceCard, + .panel, + .reviewCard, + .paymentPanel { + border-radius: 24px; + box-shadow: 0 18px 42px rgba(15, 23, 42, 0.06); + } + + .checkoutSection { + padding: 12px 16px 40px; + } + + .previewCard, + .orderCard, + .assuranceCard, + panel, + .paymentPanel { + padding: 18px; + } + + .panel { + padding: 18px; + } + + .paymentModeRow { + width: 100%; + } + + .paymentModeButton { + padding: 11px 14px; + font-size: 12px; + } + + .paymentPanelHeader { + flex-direction: column; + align-items: flex-start; + gap: 10px; + } + + .stripeMount { + min-height: 152px; + padding: 16px; + } + + .stripeMountLabel { + font-size: 12px; + } + + .stripeMountBody, + .paymentMetaCard span, + .paymentStatusCard span { + font-size: 12px; + } + + .paymentMetaCard, + .paymentStatusCard { + padding: 14px 16px; + border-radius: 18px; + } + + .previewFrame { + padding: 16px; + + img { + width: min(100%, 220px); + } + } + + .previewTitle { + font-size: 22px; + } + + .previewSubtitle { + font-size: 13px; + } + + .previewSpec, + .cartItemSubtitle, + .cartItemMeta, + .optionCard span, + .optionCard p, + .checkboxRow span { + font-size: 12px; + } + + .cartItemBody { + padding: 14px 16px 16px; + gap: 12px; + } + + .cartItemImageWrap { + padding: 16px; + } + + .cartItemTitle { + font-size: 16px; + } + + .cartItemRemove, + .cartItemPrice { + font-size: 12px; + } + + .orderHeader h2, + .panelHeader h2 { + font-size: 26px; + } + + .priceNow { + font-size: 24px; + } + + .optionCard, + .actions { + grid-template-columns: 1fr; + } + + .optionCard { + display: grid; + } + + .actions { + display: grid; + } + + .inlineMeta { + grid-template-columns: 1fr; + } + + .primaryCta, + .secondaryCta { + width: 100%; + } + } diff --git a/Store/src/pages/client/client.module.scss b/Store/src/pages/client/client.module.scss new file mode 100644 index 0000000..acdf471 --- /dev/null +++ b/Store/src/pages/client/client.module.scss @@ -0,0 +1,894 @@ +.clientPage { + background: #f5f5f7; + color: #1d1d1f; + min-height: 100vh; +} + +.topbar { + position: sticky; + top: 0; + z-index: 100; + backdrop-filter: saturate(180%) blur(20px); + background: rgba(255, 255, 255, 0.78); + border-bottom: 1px solid rgba(210, 210, 215, 0.9); +} + +.topbarInner { + width: min(calc(100% - 64px), 1240px); + margin: 0 auto; + display: grid; + grid-template-columns: 180px 1fr 220px; + align-items: center; + height: 52px; + gap: 24px; +} + +.backLink, +.helpText, +.ghostLink, +.progress span, +.logoutButton { + font-size: 12px; + line-height: 1; +} + +.backLink { + font-weight: 600; + text-decoration: none; +} + +.progress { + display: inline-flex; + justify-content: center; + align-items: center; + gap: 22px; + color: #6e6e73; +} + +.progressActive { + color: #1d1d1f; + font-weight: 600; +} + +.topbarActions { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 12px; +} + +.helpText { + margin: 0; + color: #6e6e73; +} + +.logoutButton { + padding: 7px 14px; + border-radius: 999px; + border: 1px solid #d2d2d7; + background: #fff; + cursor: pointer; +} + +.hero { + padding: 72px 32px 36px; +} + +.heroInner { + width: min(100%, 1240px); + margin: 0 auto; + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(340px, 0.9fr); + gap: 32px; + align-items: end; +} + +.eyebrow, +.sectionKicker { + margin: 0 0 12px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #6e6e73; +} + +.heroTitle { + margin: 0; + font-size: clamp(42px, 6vw, 72px); + line-height: 0.95; + letter-spacing: -0.05em; + max-width: 11ch; +} + +.heroBody { + max-width: 62ch; + margin: 22px 0 0; + font-size: 18px; + line-height: 1.55; + color: #424245; +} + +.heroMeta { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; + + div { + padding: 18px 18px 20px; + border-radius: 24px; + background: linear-gradient(180deg, #ffffff 0%, #f9f9fb 100%); + border: 1px solid #e5e5ea; + box-shadow: 0 16px 40px rgba(15, 23, 42, 0.06); + } + + strong, + span { + display: block; + } + + strong { + font-size: 16px; + margin-bottom: 6px; + } + + span { + font-size: 14px; + line-height: 1.45; + color: #6e6e73; + } +} + +.dashboardSection { + padding: 28px 32px 72px; +} + +.dashboardShell { + width: min(100%, 1240px); + margin: 0 auto; + display: grid; + gap: 24px; +} + +.loginPanel, +.dashboardPanel, +.panel, +.summaryCard, +.stockCard, +.orderCard { + background: #ffffff; + border: 1px solid #e5e5ea; + border-radius: 32px; + box-shadow: 0 24px 60px rgba(15, 23, 42, 0.08); +} + +.loginPanel, +.dashboardPanel, +.panel { + padding: 24px; +} + +.panelHeader { + display: grid; + gap: 8px; + margin-bottom: 20px; + + h2 { + margin: 0; + font-size: 32px; + line-height: 1; + letter-spacing: -0.04em; + } + + p:last-child { + margin: 0; + font-size: 15px; + color: #6e6e73; + } +} + +.loginForm { + display: grid; + gap: 18px; + max-width: 560px; +} + +.field, +.inlineField { + display: grid; + gap: 8px; + + span { + font-size: 13px; + font-weight: 600; + color: #424245; + } + + input { + width: 100%; + border: 1px solid #d2d2d7; + border-radius: 18px; + padding: 14px 16px; + font-size: 14px; + background: #fff; + } + } + +.loginActions { + display: flex; + justify-content: flex-start; +} + +.primaryCta, +.secondaryCta { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 12px 18px; + border-radius: 999px; + font-size: 14px; + font-weight: 600; + border: 0; + cursor: pointer; + text-decoration: none; +} + +.primaryCta { + background: #0071e3; + color: #fff; +} + +.secondaryCta { + background: #f5f5f7; + color: #1d1d1f; + border: 1px solid #d2d2d7; +} + +.formStatus, +.dashboardStatus { + margin: 0; + font-size: 13px; + line-height: 1.5; + color: #6e6e73; + + &[data-state="success"] { + color: #1f7a36; + } + + &[data-state="error"] { + color: #b42318; + } +} + +.dangerPanel { + padding: 24px; + background: linear-gradient(180deg, #fff8f7 0%, #ffffff 100%); + border: 1px solid rgba(220, 38, 38, 0.14); + border-radius: 32px; + box-shadow: 0 24px 60px rgba(127, 29, 29, 0.08); +} + +.dangerList { + margin: 0 0 24px; + padding-left: 18px; + display: grid; + gap: 10px; + color: #424245; + font-size: 15px; + line-height: 1.55; +} + +.dangerAction { + display: grid; + gap: 14px; + justify-items: flex-start; +} + +.dangerNote { + margin: 0; + max-width: 70ch; + font-size: 13px; + line-height: 1.6; + color: #8a1c1c; +} + +.summaryGrid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.ghostLink { + font-size: inherit; + line-height: inherit; + color: #6e6e73; + text-decoration: none; + + &:hover { + color: #1d1d1f; + } +} + +.summaryCard { + padding: 20px; + display: grid; + gap: 8px; + + strong { + font-size: clamp(28px, 4vw, 42px); + line-height: 1; + letter-spacing: -0.04em; + } + + span { + font-size: 13px; + line-height: 1.55; + color: #6e6e73; + } +} + +.dashboardGrid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 24px; + align-items: start; +} + +.toolbar { + display: grid; + gap: 20px; + margin-bottom: 24px; +} + +.searchControls { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(220px, 0.6fr) auto; + gap: 14px; + align-items: end; +} + +.searchActions { + display: flex; + gap: 10px; + align-items: center; + justify-content: flex-start; + flex-wrap: wrap; +} + +.searchHint { + margin: 0; + font-size: 13px; + line-height: 1.55; + color: #6e6e73; +} + +.selectField, +.textareaField { + display: grid; + gap: 8px; + + span { + font-size: 13px; + font-weight: 600; + color: #424245; + } + + select, + textarea { + width: 100%; + border: 1px solid #d2d2d7; + border-radius: 18px; + padding: 14px 16px; + font-size: 14px; + background: #fff; + font-family: inherit; + } + + textarea { + resize: vertical; + min-height: 112px; + } +} + +.ordersWorkspace { + display: grid; + grid-template-columns: minmax(320px, 0.78fr) minmax(0, 1.22fr); + gap: 24px; + align-items: start; +} + +.orderResults, +.stickyDetail { + display: grid; + gap: 16px; +} + +.stickyDetail { + position: sticky; + top: 84px; + align-self: start; +} + +.statRow { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + + strong { + font-size: 13px; + font-weight: 600; + } +} + +.resultCard { + width: 100%; + display: grid; + gap: 12px; + padding: 18px; + border-radius: 28px; + border: 1px solid #e5e5ea; + background: #fff; + box-shadow: 0 18px 44px rgba(15, 23, 42, 0.06); + cursor: pointer; + text-align: left; + transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease; + + &:hover { + transform: translateY(-1px); + border-color: #c8d8f8; + box-shadow: 0 22px 52px rgba(15, 23, 42, 0.08); + } +} + +.resultCardActive { + border-color: rgba(0, 113, 227, 0.24); + box-shadow: 0 0 0 3px rgba(0, 113, 227, 0.08), 0 22px 52px rgba(15, 23, 42, 0.08); + background: linear-gradient(180deg, #ffffff 0%, #f7fbff 100%); +} + +.resultCustomer { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + + strong, + p { + margin: 0; + } + + strong { + font-size: 17px; + letter-spacing: -0.03em; + } + + p { + font-size: 13px; + line-height: 1.5; + color: #6e6e73; + } +} + +.resultMeta { + display: flex; + flex-wrap: wrap; + gap: 8px; + + span { + display: inline-flex; + align-items: center; + padding: 7px 11px; + border-radius: 999px; + background: #f5f5f7; + border: 1px solid #ececf1; + font-size: 12px; + color: #424245; + } +} + +.resultSummary { + margin: 0; + font-size: 13px; + line-height: 1.55; + color: #6e6e73; +} + +.resultFooter { + display: flex; + flex-wrap: wrap; + gap: 8px; + + span { + display: inline-flex; + align-items: center; + padding: 7px 11px; + border-radius: 999px; + background: #fbfbfd; + border: 1px solid #ececf1; + font-size: 12px; + color: #6e6e73; + } +} + +.detailPanel { + padding: 24px; + background: #ffffff; + border: 1px solid #e5e5ea; + border-radius: 32px; + box-shadow: 0 24px 60px rgba(15, 23, 42, 0.08); + display: grid; + gap: 20px; +} + +.detailSection { + display: grid; + gap: 12px; +} + +.detailCallout { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + + div { + padding: 16px 18px; + border-radius: 24px; + background: linear-gradient(180deg, #ffffff 0%, #f7fbff 100%); + border: 1px solid #e8eef9; + display: grid; + gap: 6px; + } + + span { + font-size: 12px; + color: #6e6e73; + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 600; + } + + strong { + font-size: 14px; + line-height: 1.55; + font-weight: 600; + } +} + +.detailGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + + div { + padding: 14px; + border-radius: 20px; + background: #f9f9fb; + border: 1px solid #ececf1; + display: grid; + gap: 4px; + } + + span { + font-size: 12px; + color: #6e6e73; + } + + strong { + font-size: 14px; + line-height: 1.45; + } +} + +.detailSubgrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.detailAddress { + display: grid; + gap: 6px; + padding: 16px 18px; + border-radius: 24px; + background: #f9f9fb; + border: 1px solid #ececf1; + + span { + font-size: 14px; + line-height: 1.5; + } +} + +.detailItems { + margin: 0; + padding-left: 18px; + display: grid; + gap: 10px; + + li { + font-size: 13px; + line-height: 1.5; + color: #424245; + display: grid; + gap: 3px; + } + + span { + color: #6e6e73; + } +} + +.detailForm { + display: grid; + gap: 16px; + padding-top: 8px; +} + +.detailFormFooter { + display: flex; + justify-content: space-between; + align-items: center; + gap: 14px; + flex-wrap: wrap; +} + +.stockGrid, +.ordersList { + display: grid; + gap: 16px; +} + +.stockCard { + display: grid; + grid-template-columns: 132px minmax(0, 1fr); + gap: 16px; + padding: 16px; +} + +.stockCardMedia { + border-radius: 22px; + border: 1px solid rgba(210, 210, 215, 0.72); + padding: 14px; + display: grid; + place-items: center; + min-height: 120px; + + img { + display: block; + width: 100%; + max-width: 100px; + height: auto; + object-fit: contain; + } +} + +.stockCardBody { + display: grid; + gap: 14px; +} + +.stockCardHeader { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; +} + +.stockTitle, +.stockSubtitle, +.orderTitle, +.orderSubtitle, +.orderNotes, +.stockMeta { + margin: 0; +} + +.stockTitle, +.orderTitle { + font-size: 18px; + font-weight: 700; + letter-spacing: -0.03em; +} + +.stockSubtitle, +.orderSubtitle, +.stockMeta, +.orderNotes { + font-size: 13px; + line-height: 1.5; + color: #6e6e73; +} + +.stockBadge, +.orderBadge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 12px; + border-radius: 999px; + background: #f5f5f7; + border: 1px solid #e5e5ea; + font-size: 12px; + font-weight: 600; + white-space: nowrap; +} + +.orderBadge[data-status="paid"] { + background: rgba(34, 197, 94, 0.12); + color: #166534; + border-color: rgba(34, 197, 94, 0.2); +} + +.orderBadge[data-status="pending"], +.orderBadge[data-status="processing"] { + background: rgba(245, 158, 11, 0.12); + color: #92400e; + border-color: rgba(245, 158, 11, 0.2); +} + +.orderBadge[data-status="packed"] { + background: rgba(59, 130, 246, 0.1); + color: #1d4ed8; + border-color: rgba(59, 130, 246, 0.18); +} + +.orderBadge[data-status="shipped"] { + background: rgba(139, 92, 246, 0.12); + color: #6d28d9; + border-color: rgba(139, 92, 246, 0.2); +} + +.orderBadge[data-status="delivered"] { + background: rgba(16, 185, 129, 0.12); + color: #047857; + border-color: rgba(16, 185, 129, 0.2); +} + +.orderBadge[data-status="failed"], +.orderBadge[data-status="canceled"], +.orderBadge[data-status="cancelled"] { + background: rgba(239, 68, 68, 0.12); + color: #991b1b; + border-color: rgba(239, 68, 68, 0.2); +} + +.stockCardFooter { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.orderCard { + padding: 18px; + display: grid; + gap: 16px; +} + +.orderCardHeader { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; +} + +.orderMetaGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + + div { + padding: 14px; + border-radius: 20px; + background: #f9f9fb; + border: 1px solid #ececf1; + display: grid; + gap: 4px; + } + + span { + font-size: 12px; + color: #6e6e73; + } + + strong { + font-size: 14px; + line-height: 1.4; + } +} + +.orderItems { + margin: 0; + padding-left: 18px; + display: grid; + gap: 8px; + + li { + font-size: 13px; + line-height: 1.5; + color: #424245; + } +} + +.emptyState { + padding: 20px; + border-radius: 24px; + background: #f9f9fb; + border: 1px dashed #d2d2d7; + font-size: 14px; + color: #6e6e73; +} + +@media (max-width: 1080px) { + .topbarInner, + .heroInner, + .dashboardShell { + width: min(calc(100% - 32px), 100%); + } + + .heroInner, + .dashboardGrid, + .ordersWorkspace { + grid-template-columns: 1fr; + } + + .summaryGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .searchControls { + grid-template-columns: 1fr; + } + + .detailCallout, + .detailSubgrid { + grid-template-columns: 1fr; + } + + .stickyDetail { + position: static; + } +} + +@media (max-width: 720px) { + .topbarInner { + grid-template-columns: 1fr; + height: auto; + padding: 14px 0; + gap: 12px; + } + + .progress, + .topbarActions { + justify-content: flex-start; + } + + .hero, + .dashboardSection { + padding-left: 16px; + padding-right: 16px; + } + + .heroMeta, + .summaryGrid, + .orderMetaGrid, + .detailGrid, + .detailCallout, + .detailSubgrid { + grid-template-columns: 1fr; + } + + .stockCard { + grid-template-columns: 1fr; + } + + .stockCardFooter, + .orderCardHeader, + .stockCardHeader, + .detailFormFooter { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/Store/src/pages/client/index.astro b/Store/src/pages/client/index.astro new file mode 100644 index 0000000..0aab444 --- /dev/null +++ b/Store/src/pages/client/index.astro @@ -0,0 +1,370 @@ +--- +import SiteFooter from "../../components/royal-pop/SiteFooter.astro"; +import { colorways } from "../../data/royalPop"; +import BaseLayout from "../../layouts/BaseLayout.astro"; +import styles from "./client.module.scss"; + +const apiBaseUrl = process.env.API_BASE_URL || (import.meta.env.PROD ? "" : "http://localhost:8081"); +const secondaryCtaClass = styles.secondaryCta; +const emptyStateClass = styles.emptyState; +const orderCardClass = styles.orderCard; +const orderCardHeaderClass = styles.orderCardHeader; +const orderTitleClass = styles.orderTitle; +const orderSubtitleClass = styles.orderSubtitle; +const orderBadgeClass = styles.orderBadge; +const orderMetaGridClass = styles.orderMetaGrid; +const orderNotesClass = styles.orderNotes; +const orderItemsClass = styles.orderItems; +--- + + +
+
+
+ Royal Pop + +
+ Client dashboard + Orders + Stock +
+ +
+

Private access only

+ +
+
+
+ +
+
+
+

Client operations

+

Manage orders and stock without leaving the Royal Pop flow.

+

+ Sign in to review customer orders, then jump into dedicated workspaces for fulfilment updates and inventory control. +

+
+ +
+
+ Orders + Review recent customer submissions with totals, timestamps, and product details. +
+
+ Fulfilment + Use the dedicated orders workspace to update statuses, tracking numbers, and shipping notes. +
+
+ Stock + Open the dedicated stock workspace to adjust inventory counts that feed the public buy page. +
+
+
+
+ +
+
+
+
+

Sign in

+

Open the client dashboard.

+

Use the simple dashboard credentials configured for this environment.

+
+ +
+ + + +
+ +
+ +

+ Sign in to load current orders and open the fulfilment workspace. +

+
+
+ + +
+
+ + + + +
+
diff --git a/Store/src/pages/client/orders/index.astro b/Store/src/pages/client/orders/index.astro new file mode 100644 index 0000000..1626e22 --- /dev/null +++ b/Store/src/pages/client/orders/index.astro @@ -0,0 +1,715 @@ +--- +import SiteFooter from "../../../components/royal-pop/SiteFooter.astro"; +import { colorways } from "../../../data/royalPop"; +import BaseLayout from "../../../layouts/BaseLayout.astro"; +import styles from "../client.module.scss"; + +const apiBaseUrl = process.env.API_BASE_URL || (import.meta.env.PROD ? "" : "http://localhost:8081"); +const fulfillmentStatuses = [ + "pending", + "paid", + "processing", + "packed", + "shipped", + "delivered", + "cancelled", +]; +const finishOptions = [ + { id: "silver", label: "Silver" }, + { id: "black-pvd", label: "Black PVD" }, + { id: "rose-gold", label: "Rose Gold" }, +]; + +const resultCardClass = styles.resultCard; +const resultCardActiveClass = styles.resultCardActive; +const resultMetaClass = styles.resultMeta; +const resultCustomerClass = styles.resultCustomer; +const resultSummaryClass = styles.resultSummary; +const detailPanelClass = styles.detailPanel; +const detailSectionClass = styles.detailSection; +const detailGridClass = styles.detailGrid; +const detailAddressClass = styles.detailAddress; +const detailItemsClass = styles.detailItems; +const detailFormClass = styles.detailForm; +const searchControlsClass = styles.searchControls; +const toolbarClass = styles.toolbar; +const ordersWorkspaceClass = styles.ordersWorkspace; +const orderResultsClass = styles.orderResults; +const stickyDetailClass = styles.stickyDetail; +const selectFieldClass = styles.selectField; +const textareaFieldClass = styles.textareaField; +const emptyDetailClass = styles.emptyDetail; +const ghostLinkClass = styles.ghostLink; +const statRowClass = styles.statRow; +const orderBadgeClass = styles.orderBadge; +const orderTitleClass = styles.orderTitle; +const orderSubtitleClass = styles.orderSubtitle; +const orderCardHeaderClass = styles.orderCardHeader; +const fieldClass = styles.field; +const primaryCtaClass = styles.primaryCta; +const secondaryCtaClass = styles.secondaryCta; +const loginActionsClass = styles.loginActions; +const sectionKickerClass = styles.sectionKicker; +const searchActionsClass = styles.searchActions; +const searchHintClass = styles.searchHint; +const resultFooterClass = styles.resultFooter; +const detailCalloutClass = styles.detailCallout; +const detailSubgridClass = styles.detailSubgrid; +const detailFormFooterClass = styles.detailFormFooter; +--- + + +
+
+
+ Royal Pop + +
+ Client dashboard + Orders + Stock +
+ +
+

Private access only

+ +
+
+
+ +
+
+
+

Client orders

+

Search orders, open full details, and manage fulfilment in one place.

+

+ Review customer details, shipping addresses, line items, and update tracking without leaving the Royal Pop admin flow. +

+
+ +
+
+ Search + Filter by order ID, customer name, email, or tracking number. +
+
+ Detail + Open full shipping details, payment state, webhook notes, and order contents. +
+
+ Fulfilment + Move orders through approved statuses and attach carrier + tracking updates. +
+
+
+
+ +
+
+
+
+

Sign in

+

Open the orders console.

+

Use the same simple client credentials as the main dashboard.

+
+ +
+ + + +
+ +
+ +

+ Sign in to search and update customer orders. +

+
+
+ + +
+
+ + + + +
+
diff --git a/Store/src/pages/client/reseed/index.astro b/Store/src/pages/client/reseed/index.astro new file mode 100644 index 0000000..47483f7 --- /dev/null +++ b/Store/src/pages/client/reseed/index.astro @@ -0,0 +1,273 @@ +--- +import SiteFooter from "../../../components/royal-pop/SiteFooter.astro"; +import BaseLayout from "../../../layouts/BaseLayout.astro"; +import styles from "../client.module.scss"; + +const apiBaseUrl = process.env.API_BASE_URL || (import.meta.env.PROD ? "" : "http://localhost:8081"); +const dangerListClass = styles.dangerList; +const dangerPanelClass = styles.dangerPanel; +const dangerActionClass = styles.dangerAction; +const dangerNoteClass = styles.dangerNote; +--- + + +
+
+
+ Royal Pop + +
+ Client reseed +
+ +
+

Local only

+ +
+
+
+ +
+
+
+

Client reseed

+

Back up the local database, then clear it back to empty.

+

+ This private tool creates a timestamped SQL backup first, then wipes orders, order items, and inventory rows while keeping the schema intact. +

+
+ +
+
+ Backup first + A SQL snapshot is written before anything is truncated so you have a recovery point. +
+
+ App data only + Orders, order items, and inventory rows are cleared. Tables, indexes, and routes remain in place. +
+
+ No shortcuts + Use this only when you truly want a clean local state for demos, testing, or re-seeding. +
+
+
+
+ +
+
+
+
+

Sign in

+

Unlock the reseed tool.

+

Use the same private client credentials as the other dashboard pages.

+
+ +
+ + + +
+ +
+ +

+ Sign in to create a backup and reset the local database. +

+
+
+ + +
+
+ + + + +
+
diff --git a/Store/src/pages/client/stock/index.astro b/Store/src/pages/client/stock/index.astro new file mode 100644 index 0000000..095832c --- /dev/null +++ b/Store/src/pages/client/stock/index.astro @@ -0,0 +1,507 @@ +--- +import SiteFooter from "../../../components/royal-pop/SiteFooter.astro"; +import { colorways } from "../../../data/royalPop"; +import BaseLayout from "../../../layouts/BaseLayout.astro"; +import styles from "../client.module.scss"; + +const apiBaseUrl = process.env.API_BASE_URL || (import.meta.env.PROD ? "" : "http://localhost:8081"); +const finishOptions = [{ id: "silver", label: "Silver" }]; + +const stockCardClass = styles.stockCard; +const stockCardMediaClass = styles.stockCardMedia; +const stockCardBodyClass = styles.stockCardBody; +const stockCardHeaderClass = styles.stockCardHeader; +const stockTitleClass = styles.stockTitle; +const stockSubtitleClass = styles.stockSubtitle; +const stockBadgeClass = styles.stockBadge; +const inlineFieldClass = styles.inlineField; +const stockCardFooterClass = styles.stockCardFooter; +const stockMetaClass = styles.stockMeta; +const emptyStateClass = styles.emptyState; +const ghostLinkClass = styles.ghostLink; +const toolbarClass = styles.toolbar; +const searchControlsClass = styles.searchControls; +const searchActionsClass = styles.searchActions; +const searchHintClass = styles.searchHint; +const selectFieldClass = styles.selectField; +const textareaFieldClass = styles.textareaField; +const primaryCtaClass = styles.primaryCta; +const secondaryCtaClass = styles.secondaryCta; +const sectionKickerClass = styles.sectionKicker; +const statRowClass = styles.statRow; +--- + + +
+
+
+ Royal Pop + +
+ Client dashboard + Orders + Stock +
+ +
+

Private access only

+ +
+
+
+ +
+
+
+

Client stock

+

Keep storefront inventory aligned with the Royal Pop launch.

+

+ Update inventory counts for the live buy page, keep preorder configurations open when needed, and leave notes for the next fulfilment pass. +

+
+ +
+
+ Live storefront + These counts feed the public buy page availability states so customers see current stock at a glance. +
+
+ Pre-order rule + Any configuration at 0 stays open as pre-order available, so the catalog never hard-locks by mistake. +
+
+ Focused control + Use this dedicated page for counts and notes while leaving the orders workspace focused on fulfilment. +
+
+
+
+ +
+
+
+
+

Sign in

+

Open the stock console.

+

Use the same simple client credentials as the rest of the dashboard tools.

+
+ +
+ + + +
+ +
+ +

+ Sign in to review and update storefront inventory. +

+
+
+ + +
+
+ + + + +
+
diff --git a/Store/src/pages/details.astro b/Store/src/pages/details.astro new file mode 100644 index 0000000..a358ec0 --- /dev/null +++ b/Store/src/pages/details.astro @@ -0,0 +1,449 @@ +--- +import SiteFooter from "../components/royal-pop/SiteFooter.astro"; +import { colorways, defaultColorway } from "../data/royalPop"; +import BaseLayout from "../layouts/BaseLayout.astro"; +import styles from "./checkout.module.scss"; + +const finishOptions = [ + { id: "silver", label: "Silver", note: "Classic brushed 316L steel" }, + { id: "black-pvd", label: "Black PVD", note: "Stealth satin hardware" }, + { id: "rose-gold", label: "Rose Gold", note: "Warm contrast finish" }, +]; + +const pricePerKit = 49.99; +const retailPerKit = 89.99; +const defaultFinish = finishOptions[0]; +const apiBaseUrl = process.env.API_BASE_URL || (import.meta.env.PROD ? "" : "http://localhost:8081"); +const detailsStorageKey = "royal-pop-checkout-details"; +const countryOptions = [ + "Australia", + "Austria", + "Belgium", + "Brazil", + "Bulgaria", + "Canada", + "China", + "Croatia", + "Cyprus", + "Czech Republic", + "Denmark", + "Estonia", + "Finland", + "France", + "Germany", + "Greece", + "Hong Kong SAR", + "Hungary", + "Iceland", + "India", + "Indonesia", + "Ireland", + "Israel", + "Italy", + "Japan", + "Latvia", + "Lithuania", + "Luxembourg", + "Malaysia", + "Malta", + "Mexico", + "Netherlands", + "New Zealand", + "Norway", + "Philippines", + "Poland", + "Portugal", + "Qatar", + "Romania", + "Saudi Arabia", + "Singapore", + "Slovakia", + "Slovenia", + "South Africa", + "South Korea", + "Spain", + "Sweden", + "Switzerland", + "Taiwan", + "Thailand", + "Turkey", + "United Arab Emirates", + "United Kingdom", + "United States", + "Vietnam", +]; +--- + + +
+
+
+ Back to configuration + +
+ 1. Configure + 2. Details + 3. Review & Pay +
+ +

Contact & delivery details

+
+
+ +
+
+
+

Details

+

Add your contact and delivery details.

+

Add the contact and delivery details for this preorder so everything is ready for the final review.

+
+ +
+
+ What happens here + Contact info, shipping address, and any useful notes before you move on. +
+
+ What we’ll use + Your details help keep your preorder, delivery preferences, and follow-up support aligned. +
+
+ Next step + Review configuration, payment method, and submission on the final Review & Pay page. +
+
+
+
+ +
+
+ + +
+
+
+

Step 2A

+

Contact information

+

Where should we send your confirmation, shipping timeline, and install support?

+
+ +
+ + +
+
+ +
+
+

Step 2B

+

Shipping address

+

Use your intended final delivery address so rollout logistics can be planned correctly.

+
+ +
+ + + + + + + + +
+
+ +
+
+

Step 2C

+

Delivery notes

+

Add anything helpful for shipping, fit questions, or handoff notes before you continue.

+
+ +
+
+
+ Dispatch preference + Tracked worldwide shipping, with pre-orders expected to ship in around 1 month. +
+

Standard

+
+ + +
+
+ +
+
+

Next

+

Continue to review & pay

+

From here you’ll review your configuration, choose payment, and send your preorder request.

+
+ +
+ Edit configuration + +
+ +

Please double-check your email and delivery address before continuing.

+
+
+
+
+ + + + + +
+
diff --git a/Store/src/pages/index.astro b/Store/src/pages/index.astro new file mode 100644 index 0000000..3d5d00c --- /dev/null +++ b/Store/src/pages/index.astro @@ -0,0 +1,158 @@ +--- +// Path: Store/src/pages/index.astro +import ColorwaysSection from "../components/royal-pop/ColorwaysSection.astro"; +import FeatureSections from "../components/royal-pop/FeatureSections.astro"; +import HeroSection from "../components/royal-pop/HeroSection.astro"; +import PreorderSection from "../components/royal-pop/PreorderSection.astro"; +import SiteFooter from "../components/royal-pop/SiteFooter.astro"; +import SpecsSection from "../components/royal-pop/SpecsSection.astro"; +import StatsBand from "../components/royal-pop/StatsBand.astro"; +import { colorways, heroStats, specRows } from "../data/royalPop"; +import BaseLayout from "../layouts/BaseLayout.astro"; +import styles from "./index.module.scss"; + +const apiBaseUrl = process.env.API_BASE_URL || (import.meta.env.PROD ? "" : "http://localhost:8081"); +const siteUrl = "https://royal-pop-accessory.com"; +const socialImage = "/images/png/pure-white.png"; + +const structuredData = [ + { + "@context": "https://schema.org", + "@type": "Organization", + name: "Royal Pop Accessory", + url: siteUrl, + logo: `${siteUrl}/favicon/android-chrome-512x512.png`, + image: `${siteUrl}${socialImage}`, + description: + "Royal Pop Accessory creates bold interchangeable watch strap accessories in standout colourways for collectors who want a fast visual transformation.", + }, + { + "@context": "https://schema.org", + "@type": "WebSite", + name: "Royal Pop Accessory", + url: siteUrl, + description: + "Upgrade your watch with Royal Pop Accessory — bold interchangeable strap accessories in standout colourways, built to change your look in seconds.", + }, +]; +--- + + +
+ + +
+ +
+ +
+
+

From pocket to wrist.

+

Without compromise.

+
+
+ + + + + + + + + +
+
diff --git a/Store/src/pages/index.module.scss b/Store/src/pages/index.module.scss new file mode 100644 index 0000000..55d082e --- /dev/null +++ b/Store/src/pages/index.module.scss @@ -0,0 +1,118 @@ +.homepage { + background: #ffffff; + color: #1d1d1f; +} + +.nav { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; + height: 48px; + display: flex; + justify-content: center; + background: rgba(255, 255, 255, 0.72); + backdrop-filter: saturate(180%) blur(20px); + border-bottom: 1px solid rgba(210, 210, 215, 0.72); +} + +.navInner { + width: min(calc(100% - 44px), 980px); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 22px; + font-size: 12px; + line-height: 1; +} + +.navLogo { + font-size: 13px; + font-weight: 500; + color: #1d1d1f; + text-decoration: none; +} + +.navLinks { + display: none; + align-items: center; + gap: 0; + + a { + margin-left: 20px; + font-size: 12px; + color: #1d1d1f; + text-decoration: none; + opacity: 0.82; + transition: opacity 0.2s ease; + + &:hover { + opacity: 1; + text-decoration: none; + } + } + + @include respond(tablet) { + display: flex; + } +} + +.navCta { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 7px 14px; + border-radius: 999px; + background: #0071e3; + color: #ffffff !important; + opacity: 1 !important; + + &:hover { + background: #0077ed; + } +} + +.navCtaMobile { + @include respond(tablet) { + display: none; + } +} + + +.taglineBand { + padding: 80px 24px; + background: #1d1d1f; + text-align: center; +} + +.taglineInner { + width: min(100%, 980px); + margin: 0 auto; + + p { + margin: 0; + font-size: clamp(32px, 4vw, 48px); + font-weight: 700; + letter-spacing: -0.03em; + line-height: 1.08; + color: #ffffff; + } + + span { + color: #2997ff; + } + } + +:global(.reveal) { + opacity: 0; + transform: translateY(40px); + transition: + opacity 0.8s ease, + transform 0.8s ease; +} + +:global(.reveal.in) { + opacity: 1; + transform: translateY(0); +} diff --git a/Store/src/pages/thank-you.astro b/Store/src/pages/thank-you.astro new file mode 100644 index 0000000..599126b --- /dev/null +++ b/Store/src/pages/thank-you.astro @@ -0,0 +1,378 @@ +--- +import SiteFooter from "../components/royal-pop/SiteFooter.astro"; +import { colorways, defaultColorway } from "../data/royalPop"; +import BaseLayout from "../layouts/BaseLayout.astro"; +import styles from "./checkout.module.scss"; + +const pricePerKit = 49.99; +const retailPerKit = 89.99; +const apiBaseUrl = process.env.API_BASE_URL || (import.meta.env.PROD ? "" : "http://localhost:8081"); +--- + + +
+
+
+ Back to review & pay + +
+ 1. Configure + 2. Details + 3. Review & Pay +
+ +

Request received

+
+
+ +
+
+
+

Thank you

+

Your Royal Pop preorder request is in.

+

Your preorder summary and reference number are below so you can review the order exactly as submitted.

+
+ +
+
+ Confirmation ready + Your selected kit and request details are collected here in one place. +
+
+ What comes next + We’ll use this saved order record for payment confirmation, fulfilment updates, and future support. +
+
+ Reference ready + Keep this page handy if you need to double-check your selected build. +
+
+
+
+ +
+
+ + +
+
+
+

Confirmation

+

Thank you for your preorder request.

+

Your request has been received. Use this page as your final summary for the preorder you just sent through.

+
+ +
+

Confirmation

+

Royal Pop request received

+

We’ve saved your selected build and quantity so you can review the order exactly as submitted.

+
+
+ Reference + RP-BSI-SPMC-01 +
+
+ Status + Request received +
+
+
+
+ +
+
+
+ + + + + +
+
diff --git a/Store/src/scripts/royalPopCart.ts b/Store/src/scripts/royalPopCart.ts new file mode 100644 index 0000000..9c97708 --- /dev/null +++ b/Store/src/scripts/royalPopCart.ts @@ -0,0 +1,189 @@ +export const ROYAL_POP_CART_KEY = "royal-pop-cart"; +export const ROYAL_POP_BUILDER_KEY = "royal-pop-builder-selection"; +export const ROYAL_POP_CART_LIMIT = Number.POSITIVE_INFINITY; + +export interface RoyalPopSelection { + style: string; + colorwayId: string; + finishId: string; + quantity: number; +} + +export interface RoyalPopCartItem { + id: string; + style: string; + colorwayId: string; + finishId: string; + quantity: number; +} + +const isBrowser = () => typeof window !== "undefined" && typeof window.localStorage !== "undefined"; + +const readJson = (key: string, fallback: T): T => { + if (!isBrowser()) return fallback; + + try { + const raw = window.localStorage.getItem(key); + if (!raw) return fallback; + return JSON.parse(raw) as T; + } catch { + return fallback; + } +}; + +const writeJson = (key: string, value: unknown) => { + if (!isBrowser()) return; + + try { + window.localStorage.setItem(key, JSON.stringify(value)); + } catch { + // Ignore storage write failures. + } +}; + +const createItemId = () => { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + + return `rp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +}; + +const normalizeSelection = (selection: Partial): RoyalPopSelection => ({ + style: selection.style === "A" ? "A" : "B", + colorwayId: selection.colorwayId || "sorbet-pop-multi-color", + finishId: selection.finishId || "silver", + quantity: Math.max(1, Math.round(Number(selection.quantity) || 1)), +}); + +export const loadBuilderSelection = (): RoyalPopSelection | null => { + const selection = readJson | null>(ROYAL_POP_BUILDER_KEY, null); + if (!selection) return null; + return normalizeSelection(selection); +}; + +export const saveBuilderSelection = (selection: Partial) => { + writeJson(ROYAL_POP_BUILDER_KEY, normalizeSelection(selection)); +}; + +export const loadCart = (): RoyalPopCartItem[] => { + const items = readJson[]>(ROYAL_POP_CART_KEY, []); + + return items + .filter((item) => item && item.colorwayId && item.finishId) + .map((item) => ({ + id: item.id || createItemId(), + style: item.style === "A" ? "A" : "B", + colorwayId: String(item.colorwayId), + finishId: String(item.finishId), + quantity: Math.max(1, Math.round(Number(item.quantity) || 1)), + })) +; +}; + +export const saveCart = (items: RoyalPopCartItem[]) => { + writeJson( + ROYAL_POP_CART_KEY, + items.map((item) => ({ + ...item, + quantity: Math.max(1, Math.round(Number(item.quantity) || 1)), + })), + ); +}; + +export const getCartCount = () => loadCart().reduce((sum, item) => sum + Math.max(1, Number(item.quantity) || 1), 0); + +export const addSelectionToCart = (selection: Partial) => { + const normalized = normalizeSelection(selection); + const currentCart = loadCart(); + const matchingIndex = currentCart.findIndex( + (item) => + item.style === normalized.style && + item.colorwayId === normalized.colorwayId && + item.finishId === normalized.finishId, + ); + + let nextCart: RoyalPopCartItem[]; + if (matchingIndex >= 0) { + nextCart = currentCart.map((item, index) => + index === matchingIndex + ? { + ...item, + quantity: Math.max(1, Number(item.quantity) || 1) + normalized.quantity, + } + : item, + ); + } else { + nextCart = [ + ...currentCart, + { + id: createItemId(), + style: normalized.style, + colorwayId: normalized.colorwayId, + finishId: normalized.finishId, + quantity: normalized.quantity, + }, + ]; + } + + saveCart(nextCart); + + return { + cart: nextCart, + addedCount: normalized.quantity, + isFull: false, + }; +}; + +export const getEffectiveCart = (): RoyalPopCartItem[] => { + const cart = loadCart(); + if (cart.length > 0) return cart; + + const selection = loadBuilderSelection(); + if (!selection) return []; + + return [ + { + id: createItemId(), + style: selection.style, + colorwayId: selection.colorwayId, + finishId: selection.finishId, + quantity: selection.quantity, + }, + ]; +}; + +export const groupCartItems = (items: RoyalPopCartItem[]) => { + const groups = new Map(); + + items.forEach((item) => { + const key = `${item.style}::${item.colorwayId}::${item.finishId}`; + const current = groups.get(key); + const itemQuantity = Math.max(1, Math.round(Number(item.quantity) || 1)); + + if (current) { + current.quantity += itemQuantity; + return; + } + + groups.set(key, { + style: item.style, + colorwayId: item.colorwayId, + finishId: item.finishId, + quantity: itemQuantity, + }); + }); + + return Array.from(groups.values()); +}; + +export const removeCartItem = (itemId: string) => { + const nextCart = loadCart().filter((item) => item.id !== itemId); + saveCart(nextCart); + return nextCart; +}; + +export const clearCart = () => { + if (!isBrowser()) return; + window.localStorage.removeItem(ROYAL_POP_CART_KEY); +}; diff --git a/Store/src/styles/_fonts.scss b/Store/src/styles/_fonts.scss new file mode 100644 index 0000000..507f78d --- /dev/null +++ b/Store/src/styles/_fonts.scss @@ -0,0 +1,31 @@ +/* Path: Store/src/styles/_fonts.scss */ + +// @font-face { +// font-family: "CaveatBrush"; +// src: url("/fonts/CaveatBrush/CaveatBrush-Regular.woff2") format("woff2"); +// font-display: swap; +// } + +// @font-face { +// font-family: "Poppins"; +// src: url("/fonts/Poppins/Poppins-Light.woff2") format("woff2"); +// font-weight: 300; +// font-style: normal; +// font-display: swap; +// } + +// @font-face { +// font-family: "Poppins"; +// src: url("/fonts/Poppins/Poppins-Regular.woff2") format("woff2"); +// font-weight: 400; +// font-style: normal; +// font-display: swap; +// } + +// @font-face { +// font-family: "Poppins"; +// src: url("/fonts/Poppins/Poppins-Medium.woff2") format("woff2"); +// font-weight: 500; +// font-style: normal; +// font-display: swap; +// } diff --git a/Store/src/styles/_reset.scss b/Store/src/styles/_reset.scss new file mode 100644 index 0000000..e52beb7 --- /dev/null +++ b/Store/src/styles/_reset.scss @@ -0,0 +1,52 @@ +/* Path: Store/src/styles/_reset.scss */ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +* { + margin: 0; +} + +html, +body { + height: 100%; +} + +body { + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +img, +picture, +video, +canvas, +svg { + display: block; + max-width: 100%; +} + +input, +button, +textarea, +select { + font: inherit; +} + +p, +h1, +h2, +h3, +h4, +h5, +h6 { + overflow-wrap: break-word; +} + +#root, +#__next { + isolation: isolate; +} diff --git a/Store/src/styles/main.scss b/Store/src/styles/main.scss new file mode 100644 index 0000000..7ab9eea --- /dev/null +++ b/Store/src/styles/main.scss @@ -0,0 +1,89 @@ +/* Path: Store/src/styles/main.scss */ + +@use "./reset" as *; +@use "./fonts" as *; + +html { + background-color: var(--bg); + + // TODO: Transition to be added + transition: + background-color var(--transition-speed) var(--transition-ease), + color var(--transition-speed) var(--transition-ease); + + color: var(--text); + font-family: + "Geist", + // TODO: Custom font, added later + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + Oxygen, + Ubuntu, + Cantarell, + "Open Sans", + "Helvetica Neue", + sans-serif; + + overflow-x: hidden; + scroll-behavior: smooth; +} + +body { + background-color: var(--bg); +} + +h1 { + @include text-largest; +} + +h2 { + @include text-large; +} + +h3 { + @include text-medium; +} + +h4 { + @include text-small; +} + +h5 { + @include text-smaller; +} + +p, +span, +button, +input, +textarea, +sub, +a { + @include text-smallest; +} + +a { + color: inherit; + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +h1, +h2, +h3, +h4, +h5, +p, +span, +a, +button, +input, +textarea { + transition: color var(--transition-speed) var(--transition-ease); +} diff --git a/Store/src/styles/vars.scss b/Store/src/styles/vars.scss new file mode 100644 index 0000000..a89b3bb --- /dev/null +++ b/Store/src/styles/vars.scss @@ -0,0 +1,185 @@ +/* Path: Store/src/styles/vars.scss */ + +:root { + --transition-speed: 0.5s; + --transition-ease: ease-in-out; + + --gray-50: hsl(40 25% 85%); + --gray-100: hsl(40 23% 80%); + --gray-200: hsl(40 20% 77%); + --gray-300: hsl(40 20% 70%); + --gray-400: hsl(40 10% 50%); + --gray-500: hsl(40 5% 40%); + --gray-600: hsl(40 5% 30%); + --gray-700: hsl(40 5% 25%); + --gray-800: hsl(40 5% 20%); + --gray-900: hsl(40 5% 15%); + + --brown-300: hsl(27 39.2% 86.5%); + --brown-400: hsl(27 39.2% 76.5%); + --brown-500: hsl(27 39.2% 66.5%); + --brown-600: hsl(27 39.2% 56.5%); + --brown-700: hsl(27 39.2% 46.5%); + --brown-800: hsl(27 39.2% 36.5%); + + --green-300: hsl(115 43.1% 70%); + --green-400: hsl(115 43.1% 70%); + --green-500: hsl(115 43.1% 60%); + --green-600: hsl(115 43.1% 50%); + --green-700: hsl(115 43.1% 40%); + + --red-500: hsl(359 46.6% 50.8%); + --red-600: hsl(359 46.6% 40.8%); +} + +:root { + // Background and Text Colors + --bg: var(--gray-50); + --text: var(--gray-800); + --text-muted: var(--gray-700); + + // Accent + --primary: var(--brown-700); + --primary-hover: var(--brown-800); + --secondary: var(--green-500); + --secondary-hover: var(--green-600); + --warning: var(--red-500); + --warning-hover: var(--red-600); + + // Typical Card Button Colors + --bg-1: var(--gray-100); + --bg-1-hover: var(--gray-200); + + --bg-2: var(--gray-200); + --bg-2-hover: var(--gray-300); + + --bg-3: var(--gray-300); + --bg-3-hover: var(--gray-400); + + --box-shadow: 0 8px 16px hsl(0 0% 0% / 0.1); +} + +[data-color-scheme="dark"] { + // Background and Text Colors + --bg: var(--gray-900); + --text: var(--gray-50); + --text-muted: var(--gray-400); + + // Accent + --primary: var(--brown-700); + --primary-hover: var(--brown-600); + --secondary: var(--green-700); + --secondary-hover: var(--green-600); + + // Typical Card Button Colors + --bg-1: var(--gray-800); + --bg-1-hover: var(--gray-700); + + --bg-2: var(--gray-700); + --bg-2-hover: var(--gray-600); + + --bg-3: var(--gray-600); + --bg-3-hover: var(--gray-500); +} + +// Breakpoints +$mobile-narrow: 519px; +$phablet: 640px; +$compact: 720px; +$mobile: 768px; +$wide: 880px; +$desktop-sm: 960px; +$desktop-md: 980px; +$tablet: 1024px; +$workspace: 1080px; +$desktop-lg: 1120px; +$desktop: 1440px; + +// The Mixin: Now checks for MIN-width +@mixin respond($breakpoint) { + @if $breakpoint == phablet { + @media (min-width: $phablet) { + @content; + } + } @else if $breakpoint == compact { + @media (min-width: $compact) { + @content; + } + } @else if $breakpoint == mobile { + @media (min-width: $mobile) { + @content; + } + } @else if $breakpoint == wide { + @media (min-width: $wide) { + @content; + } + } @else if $breakpoint == desktop-sm { + @media (min-width: $desktop-sm) { + @content; + } + } @else if $breakpoint == desktop-md { + @media (min-width: $desktop-md) { + @content; + } + } @else if $breakpoint == tablet { + @media (min-width: $tablet) { + @content; + } + } @else if $breakpoint == workspace { + @media (min-width: $workspace) { + @content; + } + } @else if $breakpoint == desktop-lg { + @media (min-width: $desktop-lg) { + @content; + } + } @else if $breakpoint == desktop { + @media (min-width: $desktop) { + @content; + } + } +} + +@mixin respond-max($breakpoint) { + @if $breakpoint == mobile-narrow { + @media (max-width: $mobile-narrow) { + @content; + } + } +} + +@mixin text-smallest { + font-size: 1rem; + font-size: clamp(1rem, 0.9295774647887324rem + 0.300469483568075vw, 1.2rem); + font-weight: 300; +} + +@mixin text-smaller { + font-size: 1.1rem; + font-size: clamp(1.1rem, 0.959154929577465rem + 0.60093896713615vw, 1.5rem); + font-weight: 300; +} + +@mixin text-small { + font-size: 1.25rem; + font-size: clamp(1.25rem, 0.9859154929577465rem + 1.1267605633802815vw, 2rem); + font-weight: 300; +} + +@mixin text-medium { + font-size: 1.5rem; + font-size: clamp(1.5rem, 1.147887323943662rem + 1.5023474178403755vw, 2.5rem); + font-weight: 400; +} + +@mixin text-large { + font-size: 1.75rem; + font-size: clamp(1.75rem, 1.3098591549295775rem + 1.8779342723004695vw, 3rem); + font-weight: 400; +} + +@mixin text-largest { + font-size: 2rem; + font-size: clamp(2rem, 1.295774647887324rem + 3.004694835680751vw, 4rem); + font-weight: 400; +} diff --git a/Store/tsconfig.json b/Store/tsconfig.json new file mode 100644 index 0000000..a9210e6 --- /dev/null +++ b/Store/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"] +}