Version 1
@@ -0,0 +1,6 @@
|
|||||||
|
.git
|
||||||
|
.DS_Store
|
||||||
|
Store/node_modules
|
||||||
|
Store/dist
|
||||||
|
Backend/tmp
|
||||||
|
Backend/Backups
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.git
|
||||||
|
.DS_Store
|
||||||
|
tmp
|
||||||
@@ -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"]
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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/'
|
||||||
@@ -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
|
||||||
@@ -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}}'
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
mod dev "Dev"
|
||||||
|
mod prod
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
mod dev
|
||||||
|
mod prod
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -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:
|
||||||
@@ -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:
|
||||||
@@ -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:
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
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.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
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
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.DS_Store
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
@@ -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
|
||||||
@@ -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`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
allowBuilds:
|
||||||
|
"@parcel/watcher": true
|
||||||
|
esbuild: true
|
||||||
|
sharp: true
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import autoprefixer from "autoprefixer";
|
||||||
|
import cssnano from "cssnano";
|
||||||
|
import postcssPresetEnv from "postcss-preset-env";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
plugins: [autoprefixer(), postcssPresetEnv(), cssnano()],
|
||||||
|
};
|
||||||
@@ -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 <head> section:
|
||||||
|
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon/favicon.ico">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png">
|
||||||
|
<link rel="manifest" href="/favicon/site.webmanifest">
|
||||||
|
<meta name="msapplication-TileColor" content="#e9e9e9">
|
||||||
|
<meta name="theme-color" content="#e9e9e9">
|
||||||
|
|
||||||
|
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!
|
||||||
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<browserconfig>
|
||||||
|
<msapplication>
|
||||||
|
<tile>
|
||||||
|
<square150x150logo src="/favicon/mstile-150x150.png"/>
|
||||||
|
<square310x310logo src="/favicon/mstile-310x310.png"/>
|
||||||
|
<TileColor>#e9e9e9</TileColor>
|
||||||
|
</tile>
|
||||||
|
</msapplication>
|
||||||
|
</browserconfig>
|
||||||
|
After Width: | Height: | Size: 707 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 47 KiB |
@@ -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"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.7 MiB |
|
After Width: | Height: | Size: 3.6 MiB |
|
After Width: | Height: | Size: 3.4 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 3.3 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 3.8 MiB |
|
After Width: | Height: | Size: 3.6 MiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 296 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 108 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://royal-pop-accessory.com/sitemap.xml
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://royal-pop-accessory.com/</loc>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://royal-pop-accessory.com/buy</loc>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
@@ -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;
|
||||||
|
---
|
||||||
|
|
||||||
|
<section id="colorways" class:list={[styles.section, "reveal"]}>
|
||||||
|
<div class={styles.inner}>
|
||||||
|
<div class={styles.heading}>
|
||||||
|
<p class={styles.sectionLabel}>The Palette</p>
|
||||||
|
<h2 class={styles.sectionHeadline}>
|
||||||
|
Eight ways
|
||||||
|
<br />
|
||||||
|
to stand out.
|
||||||
|
</h2>
|
||||||
|
<p class={styles.sectionBody}>Each Royal Pop conversion kit ships as a complete cohesive system - strap, lug adapter, and matched case build - tied to your chosen colourway.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles.previewArea} data-colorways-root>
|
||||||
|
<div id="cw-preview" class={styles.previewCard} data-preview-surface style={`background:${active.previewBackground};`}>
|
||||||
|
<img class={styles.watchImage} src={active.previewImage} alt={`${active.name} Royal Pop preview`} data-colorway-preview loading="lazy" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles.previewText}>
|
||||||
|
<h3 class={styles.colorName} data-cw-name>{active.name}</h3>
|
||||||
|
<p class={styles.colorSubtitle} data-cw-sub>{active.subtitle}</p>
|
||||||
|
<div class={styles.styleBadge} data-cw-style-badge data-style={active.style.toLowerCase()}>
|
||||||
|
<span class={styles.dot}></span>
|
||||||
|
<span data-cw-style-text>{active.styleText}</span>
|
||||||
|
</div>
|
||||||
|
<p class={styles.crownDesc} data-cw-crown-desc>{active.crownDescription}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles.swatches} role="tablist" aria-label="Royal Pop colorways">
|
||||||
|
{
|
||||||
|
colorways.map((colorway) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={styles.colorSwatch}
|
||||||
|
style={`background:${colorway.swatchColor};`}
|
||||||
|
data-colorway-trigger
|
||||||
|
data-colorway-id={colorway.id}
|
||||||
|
aria-label={`${colorway.name} - ${colorway.subtitle}`}
|
||||||
|
aria-pressed={String(colorway.id === active.id)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={styles.cardGrid}>
|
||||||
|
{
|
||||||
|
colorways.map((colorway) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={styles.gridCard}
|
||||||
|
data-colorway-card
|
||||||
|
data-colorway-id={colorway.id}
|
||||||
|
aria-label={`Select ${colorway.name} colorway`}
|
||||||
|
aria-pressed={String(colorway.id === active.id)}
|
||||||
|
>
|
||||||
|
<div class={styles.gridCardImageWrap}>
|
||||||
|
<img class={styles.watchImage} src={colorway.cardImage} alt={`${colorway.name} product view`} loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<div class={styles.gridCardText}>
|
||||||
|
<strong>{colorway.name}</strong>
|
||||||
|
<span>{colorway.subtitle}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script define:vars={{ colorways }}>
|
||||||
|
const root = document.querySelector("[data-colorways-root]");
|
||||||
|
|
||||||
|
if (root) {
|
||||||
|
const preview = root.querySelector("[data-colorway-preview]");
|
||||||
|
const previewSurface = root.querySelector("[data-preview-surface]");
|
||||||
|
const name = root.querySelector("[data-cw-name]");
|
||||||
|
const subtitle = root.querySelector("[data-cw-sub]");
|
||||||
|
const badge = root.querySelector("[data-cw-style-badge]");
|
||||||
|
const badgeText = root.querySelector("[data-cw-style-text]");
|
||||||
|
const crownDesc = root.querySelector("[data-cw-crown-desc]");
|
||||||
|
const triggers = Array.from(root.querySelectorAll("[data-colorway-trigger]"));
|
||||||
|
const cards = Array.from(root.querySelectorAll("[data-colorway-card]"));
|
||||||
|
|
||||||
|
const crownStatValue = document.querySelector('[data-stat-id="crown-stat-value"]');
|
||||||
|
const crownStatLabel = document.querySelector('[data-stat-id="crown-stat-label"]');
|
||||||
|
const crownSpec = document.querySelector('[data-spec-id="crownPosition"]');
|
||||||
|
|
||||||
|
const applyColorway = (id) => {
|
||||||
|
const next = colorways.find((entry) => entry.id === id);
|
||||||
|
|
||||||
|
if (!next || !preview || !previewSurface || !name || !subtitle || !badge || !badgeText || !crownDesc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
preview.setAttribute("src", next.previewImage);
|
||||||
|
preview.setAttribute("alt", `${next.name} Royal Pop preview`);
|
||||||
|
previewSurface.setAttribute("style", `background:${next.previewBackground};`);
|
||||||
|
name.textContent = next.name;
|
||||||
|
subtitle.textContent = next.subtitle;
|
||||||
|
badge.setAttribute("data-style", next.style.toLowerCase());
|
||||||
|
badgeText.textContent = next.styleText;
|
||||||
|
crownDesc.textContent = next.crownDescription;
|
||||||
|
|
||||||
|
if (crownSpec) crownSpec.textContent = next.crownSpec;
|
||||||
|
if (crownStatValue) crownStatValue.textContent = next.crownStatValue;
|
||||||
|
if (crownStatLabel) crownStatLabel.textContent = next.crownStatLabel;
|
||||||
|
|
||||||
|
triggers.forEach((trigger) => {
|
||||||
|
const isActive = trigger.getAttribute("data-colorway-id") === next.id;
|
||||||
|
trigger.setAttribute("aria-pressed", String(isActive));
|
||||||
|
trigger.classList.toggle("is-active", isActive);
|
||||||
|
});
|
||||||
|
|
||||||
|
cards.forEach((card) => {
|
||||||
|
const isActive = card.getAttribute("data-colorway-id") === next.id;
|
||||||
|
card.setAttribute("aria-pressed", String(isActive));
|
||||||
|
card.classList.toggle("is-active", isActive);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
triggers.forEach((trigger) => {
|
||||||
|
trigger.addEventListener("click", () => {
|
||||||
|
const id = trigger.getAttribute("data-colorway-id");
|
||||||
|
|
||||||
|
if (id) applyColorway(id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cards.forEach((card) => {
|
||||||
|
card.addEventListener("click", () => {
|
||||||
|
const id = card.getAttribute("data-colorway-id");
|
||||||
|
|
||||||
|
if (id) applyColorway(id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</section>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||