Network
+Currency
+diff --git a/src/controller/comm/order_controller.go b/src/controller/comm/order_controller.go index 039a6a9..4c58c4d 100644 --- a/src/controller/comm/order_controller.go +++ b/src/controller/comm/order_controller.go @@ -1,6 +1,7 @@ package comm import ( + "encoding/json" "fmt" "log" @@ -26,6 +27,30 @@ func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) { return c.SucJson(ctx, resp) } +// SwitchNetwork 切换支付网络,创建或返回子订单 +func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) { + req := new(request.SwitchNetworkRequest) + if err = ctx.Bind(req); err != nil { + return c.FailJson(ctx, constant.ParamsMarshalErr) + } + if err = c.ValidateStruct(ctx, req); err != nil { + return c.FailJson(ctx, err) + } + resp, err := service.SwitchNetwork(req) + if err != nil { + return c.FailJson(ctx, err) + } + + jsonBytes, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return c.FailJson(ctx, err) + } + + fmt.Printf("switch network response: \n%s", string(jsonBytes)) + + return c.SucJson(ctx, resp) +} + func (c *BaseCommController) CreateTransactionAndRedirect(ctx echo.Context) (err error) { req := new(request.CreateTransactionRequest) if err = ctx.Bind(req); err != nil { diff --git a/src/controller/comm/pay_controller.go b/src/controller/comm/pay_controller.go index 1bc9ebd..66e6850 100644 --- a/src/controller/comm/pay_controller.go +++ b/src/controller/comm/pay_controller.go @@ -1,6 +1,8 @@ package comm import ( + "encoding/json" + "fmt" "html/template" "net/http" "path/filepath" @@ -30,7 +32,13 @@ func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) { if err != nil { return ctx.String(http.StatusOK, err.Error()) } - resp.Network = "TRON" + + jsonByte, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return ctx.String(http.StatusOK, err.Error()) + } + fmt.Printf("%v\n", string(jsonByte)) + return tmpl.Execute(ctx.Response(), resp) } diff --git a/src/model/data/order_data.go b/src/model/data/order_data.go index b5a8e16..430264b 100644 --- a/src/model/data/order_data.go +++ b/src/model/data/order_data.go @@ -108,6 +108,97 @@ func UpdateOrderIsExpirationById(id uint64, expirationCutoff time.Time) (bool, e return result.RowsAffected > 0, result.Error } +// CountActiveSubOrders counts sub-orders with status=WaitPay under a parent. +func CountActiveSubOrders(parentTradeId string) (int64, error) { + var count int64 + err := dao.Mdb.Model(&mdb.Orders{}). + Where("parent_trade_id = ?", parentTradeId). + Where("status = ?", mdb.StatusWaitPay). + Count(&count).Error + return count, err +} + +// GetSubOrderByTokenNetwork finds an existing active sub-order matching token+network under a parent. +func GetSubOrderByTokenNetwork(parentTradeId string, token string, network string) (*mdb.Orders, error) { + order := new(mdb.Orders) + err := dao.Mdb.Model(order). + Where("parent_trade_id = ?", parentTradeId). + Where("token = ?", token). + Where("network = ?", network). + Where("status = ?", mdb.StatusWaitPay). + Limit(1). + Find(order).Error + return order, err +} + +// GetSiblingSubOrders returns active sub-orders under the same parent, excluding the given trade_id. +func GetSiblingSubOrders(parentTradeId string, excludeTradeId string) ([]mdb.Orders, error) { + var orders []mdb.Orders + err := dao.Mdb.Model(&mdb.Orders{}). + Where("parent_trade_id = ?", parentTradeId). + Where("trade_id != ?", excludeTradeId). + Where("status = ?", mdb.StatusWaitPay). + Find(&orders).Error + return orders, err +} + +// MarkParentOrderSuccess updates the parent order with the sub-order's payment details. +// Token and network are NOT overwritten — the parent keeps its original values. +func MarkParentOrderSuccess(parentTradeId string, sub *mdb.Orders) (bool, error) { + result := dao.Mdb.Model(&mdb.Orders{}). + Where("trade_id = ?", parentTradeId). + Where("status = ?", mdb.StatusWaitPay). + Updates(map[string]interface{}{ + "status": mdb.StatusPaySuccess, + "block_transaction_id": sub.BlockTransactionId, + "callback_confirm": mdb.CallBackConfirmNo, + "actual_amount": sub.ActualAmount, + "receive_address": sub.ReceiveAddress, + }) + return result.RowsAffected > 0, result.Error +} + +// MarkOrderSelected sets is_selected=true for the given trade_id. +func MarkOrderSelected(tradeId string) error { + return dao.Mdb.Model(&mdb.Orders{}). + Where("trade_id = ?", tradeId). + Update("is_selected", true).Error +} + +// RefreshOrderExpiration resets created_at to now so the expiration timer restarts. +// Called on the parent order when a sub-order is created or returned. +func RefreshOrderExpiration(tradeId string) error { + return dao.Mdb.Model(&mdb.Orders{}). + Where("trade_id = ?", tradeId). + Update("created_at", time.Now()).Error +} + +// ResetCallbackConfirmOk sets callback_confirm back to Ok. +// Prevents the callback worker from retrying a sub-order with an empty notify_url. +func ResetCallbackConfirmOk(tradeId string) error { + return dao.Mdb.Model(&mdb.Orders{}). + Where("trade_id = ?", tradeId). + Update("callback_confirm", mdb.CallBackConfirmOk).Error +} + +// GetActiveSubOrders returns all active sub-orders under a parent. +func GetActiveSubOrders(parentTradeId string) ([]mdb.Orders, error) { + var orders []mdb.Orders + err := dao.Mdb.Model(&mdb.Orders{}). + Where("parent_trade_id = ?", parentTradeId). + Where("status = ?", mdb.StatusWaitPay). + Find(&orders).Error + return orders, err +} + +// ExpireOrderByTradeId marks a single order as expired if still waiting. +func ExpireOrderByTradeId(tradeId string) error { + return dao.Mdb.Model(&mdb.Orders{}). + Where("trade_id = ?", tradeId). + Where("status = ?", mdb.StatusWaitPay). + Update("status", mdb.StatusExpired).Error +} + // GetTradeIdByWalletAddressAndAmountAndToken resolves the reserved trade id by network, address, token and amount. func GetTradeIdByWalletAddressAndAmountAndToken(network string, address string, token string, amount float64) (string, error) { scaledAmount, _ := normalizeLockAmount(amount) diff --git a/src/model/mdb/orders_mdb.go b/src/model/mdb/orders_mdb.go index d5793eb..9350b2c 100644 --- a/src/model/mdb/orders_mdb.go +++ b/src/model/mdb/orders_mdb.go @@ -15,6 +15,7 @@ const ( type Orders struct { TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id"` OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id"` // the order id is generated by client, and will notify client when order is paid + ParentTradeId string `gorm:"column:parent_trade_id;index:idx_orders_parent_trade_id;default:''" json:"parent_trade_id"` BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id"` Amount float64 `gorm:"column:amount" json:"amount"` Currency string `gorm:"column:currency" json:"currency"` @@ -28,6 +29,7 @@ type Orders struct { Name string `gorm:"column:name" json:"name"` CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num"` CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm"` + IsSelected bool `gorm:"column:is_selected;default:false" json:"is_selected"` PaymentType string `gorm:"column:payment_type" json:"payment_type"` BaseModel } diff --git a/src/model/mdb/wallet_address_mdb.go b/src/model/mdb/wallet_address_mdb.go index 7cf2fdb..cdbb194 100644 --- a/src/model/mdb/wallet_address_mdb.go +++ b/src/model/mdb/wallet_address_mdb.go @@ -8,7 +8,7 @@ const ( const ( NetworkTron = "tron" NetworkSolana = "solana" - NetworkEthereum = "eth" + NetworkEthereum = "ethereum" ) type WalletAddress struct { diff --git a/src/model/request/order_request.go b/src/model/request/order_request.go index f2ca801..b42e38e 100644 --- a/src/model/request/order_request.go +++ b/src/model/request/order_request.go @@ -38,3 +38,18 @@ type OrderProcessingRequest struct { TradeId string BlockTransactionId string } + +// SwitchNetworkRequest 切换支付网络 +type SwitchNetworkRequest struct { + TradeId string `json:"trade_id" validate:"required"` + Token string `json:"token" validate:"required"` + Network string `json:"network" validate:"required"` +} + +func (r SwitchNetworkRequest) Translates() map[string]string { + return validate.MS{ + "TradeId": "订单号", + "Token": "币种", + "Network": "网络", + } +} diff --git a/src/model/response/pay_response.go b/src/model/response/pay_response.go index 6db0429..190194e 100644 --- a/src/model/response/pay_response.go +++ b/src/model/response/pay_response.go @@ -11,6 +11,7 @@ type CheckoutCounterResponse struct { ExpirationTime int64 `json:"expiration_time"` // 过期时间 时间戳 RedirectUrl string `json:"redirect_url"` CreatedAt int64 `json:"created_at"` // 订单创建时间 时间戳 + IsSelected bool `json:"is_selected"` } type CheckStatusResponse struct { diff --git a/src/model/service/order_service.go b/src/model/service/order_service.go index 4d00d7f..27aced1 100644 --- a/src/model/service/order_service.go +++ b/src/model/service/order_service.go @@ -157,6 +157,69 @@ func OrderProcessing(req *request.OrderProcessingRequest) error { if err = data.UnLockTransaction(req.Network, req.ReceiveAddress, req.Token, req.Amount); err != nil { log.Sugar.Warnf("[order] unlock transaction after pay success failed, trade_id=%s, err=%v", req.TradeId, err) } + + // Load order to check parent-child relationship + order, err := data.GetOrderInfoByTradeId(req.TradeId) + if err != nil { + return nil + } + + // Parent order paid directly: expire all sub-orders and release their locks + if order.ParentTradeId == "" { + subs, subErr := data.GetActiveSubOrders(order.TradeId) + if subErr != nil { + log.Sugar.Errorf("[order] get sub-orders for parent failed, trade_id=%s, err=%v", order.TradeId, subErr) + return nil + } + for _, sub := range subs { + if err = data.ExpireOrderByTradeId(sub.TradeId); err != nil { + log.Sugar.Warnf("[order] expire sub-order failed, trade_id=%s, err=%v", sub.TradeId, err) + } + if err = data.UnLockTransaction(sub.Network, sub.ReceiveAddress, sub.Token, sub.ActualAmount); err != nil { + log.Sugar.Warnf("[order] unlock sub-order transaction failed, trade_id=%s, err=%v", sub.TradeId, err) + } + } + return nil + } + + // Sub-order should not trigger its own callback (notify_url is empty). + // OrderSuccessWithTransaction unconditionally sets callback_confirm=No, + // reset it here to prevent the callback worker from retrying an empty URL. + if err = data.ResetCallbackConfirmOk(order.TradeId); err != nil { + log.Sugar.Warnf("[order] reset sub-order callback_confirm failed, trade_id=%s, err=%v", order.TradeId, err) + } + + parent, err := data.GetOrderInfoByTradeId(order.ParentTradeId) + if err != nil { + log.Sugar.Errorf("[order] load parent order failed, parent_trade_id=%s, err=%v", order.ParentTradeId, err) + return nil + } + + // Mark parent as paid with sub-order's payment details + if _, err = data.MarkParentOrderSuccess(parent.TradeId, order); err != nil { + log.Sugar.Errorf("[order] mark parent success failed, parent_trade_id=%s, err=%v", parent.TradeId, err) + } + + // Release parent's own wallet lock + if err = data.UnLockTransaction(parent.Network, parent.ReceiveAddress, parent.Token, parent.ActualAmount); err != nil { + log.Sugar.Warnf("[order] unlock parent transaction failed, parent_trade_id=%s, err=%v", parent.TradeId, err) + } + + // Expire sibling sub-orders and release their locks + siblings, err := data.GetSiblingSubOrders(parent.TradeId, order.TradeId) + if err != nil { + log.Sugar.Errorf("[order] get sibling sub-orders failed, parent_trade_id=%s, err=%v", parent.TradeId, err) + return nil + } + for _, sib := range siblings { + if err = data.ExpireOrderByTradeId(sib.TradeId); err != nil { + log.Sugar.Warnf("[order] expire sibling failed, trade_id=%s, err=%v", sib.TradeId, err) + } + if err = data.UnLockTransaction(sib.Network, sib.ReceiveAddress, sib.Token, sib.ActualAmount); err != nil { + log.Sugar.Warnf("[order] unlock sibling transaction failed, trade_id=%s, err=%v", sib.TradeId, err) + } + } + return nil } @@ -214,3 +277,141 @@ func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) { } return order, nil } + +const MaxSubOrders = 2 + +// SwitchNetwork creates or returns an existing sub-order for a different token+network. +func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounterResponse, error) { + gCreateTransactionLock.Lock() + defer gCreateTransactionLock.Unlock() + + token := strings.ToUpper(strings.TrimSpace(req.Token)) + network := strings.ToLower(strings.TrimSpace(req.Network)) + + // 1. Load parent order + parent, err := data.GetOrderInfoByTradeId(req.TradeId) + if err != nil { + return nil, err + } + if parent.ID <= 0 { + return nil, constant.OrderNotExists + } + if parent.ParentTradeId != "" { + return nil, constant.CannotSwitchSubOrder + } + if parent.Status != mdb.StatusWaitPay { + return nil, constant.OrderNotWaitPay + } + + // 2. Same token+network as parent → mark selected and return + if strings.EqualFold(parent.Token, token) && strings.EqualFold(parent.Network, network) { + _ = data.MarkOrderSelected(parent.TradeId) + parent.IsSelected = true + return buildCheckoutResponse(parent), nil + } + + // 3. Existing active sub-order for this token+network → return it + existing, err := data.GetSubOrderByTokenNetwork(parent.TradeId, token, network) + if err != nil { + return nil, err + } + if existing.ID > 0 { + _ = data.MarkOrderSelected(parent.TradeId) + _ = data.MarkOrderSelected(existing.TradeId) + _ = data.RefreshOrderExpiration(parent.TradeId) + existing.IsSelected = true + return buildCheckoutResponse(existing), nil + } + + // 4. Check sub-order limit + count, err := data.CountActiveSubOrders(parent.TradeId) + if err != nil { + return nil, err + } + if count >= MaxSubOrders { + return nil, constant.SubOrderLimitExceeded + } + + // 5. Calculate amount for the new network + rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(parent.Currency)) + if rate <= 0 { + return nil, constant.RateAmountErr + } + decimalPayAmount := decimal.NewFromFloat(parent.Amount) + decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate)) + if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 { + return nil, constant.PayAmountErr + } + + // 6. Find and lock wallet + walletAddress, err := data.GetAvailableWalletAddressByNetwork(network) + if err != nil { + return nil, err + } + if len(walletAddress) <= 0 { + return nil, constant.NotAvailableWalletAddress + } + + subTradeID := GenerateCode() + amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), 2) + availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(subTradeID, network, token, amount, walletAddress) + if err != nil { + return nil, err + } + if availableAddress == "" { + return nil, constant.NotAvailableAmountErr + } + + // 7. Create sub-order + tx := dao.Mdb.Begin() + subOrder := &mdb.Orders{ + TradeId: subTradeID, + OrderId: subTradeID, // sub-order uses its own trade_id as order_id (unique constraint) + ParentTradeId: parent.TradeId, + Amount: parent.Amount, + Currency: parent.Currency, + ActualAmount: availableAmount, + ReceiveAddress: availableAddress, + Token: token, + Network: network, + Status: mdb.StatusWaitPay, + IsSelected: true, + NotifyUrl: "", + RedirectUrl: parent.RedirectUrl, + Name: parent.Name, + CallBackConfirm: mdb.CallBackConfirmOk, // don't trigger callback on sub-order + PaymentType: parent.PaymentType, + } + if err = data.CreateOrderWithTransaction(tx, subOrder); err != nil { + tx.Rollback() + _ = data.UnLockTransactionByTradeId(subTradeID) + return nil, err + } + if err = tx.Commit().Error; err != nil { + tx.Rollback() + _ = data.UnLockTransactionByTradeId(subTradeID) + return nil, err + } + + // Mark parent as selected and refresh its expiration to match the sub-order + _ = data.MarkOrderSelected(parent.TradeId) + _ = data.RefreshOrderExpiration(parent.TradeId) + + return buildCheckoutResponse(subOrder), nil +} + +func buildCheckoutResponse(order *mdb.Orders) *response.CheckoutCounterResponse { + return &response.CheckoutCounterResponse{ + TradeId: order.TradeId, + Amount: order.Amount, + ActualAmount: order.ActualAmount, + Token: order.Token, + Currency: order.Currency, + ReceiveAddress: order.ReceiveAddress, + Network: order.Network, + ExpirationTime: order.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(), + RedirectUrl: order.RedirectUrl, + CreatedAt: order.CreatedAt.TimestampMilli(), + IsSelected: order.IsSelected, + } +} diff --git a/src/model/service/pay_service.go b/src/model/service/pay_service.go index c1e54bf..011707d 100644 --- a/src/model/service/pay_service.go +++ b/src/model/service/pay_service.go @@ -32,6 +32,7 @@ func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterRespo ExpirationTime: orderInfo.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(), RedirectUrl: orderInfo.RedirectUrl, CreatedAt: orderInfo.CreatedAt.TimestampMilli(), + IsSelected: orderInfo.IsSelected, } return resp, nil } diff --git a/src/route/router.go b/src/route/router.go index 37a6943..ee54b32 100644 --- a/src/route/router.go +++ b/src/route/router.go @@ -26,6 +26,7 @@ func RegisterRoute(e *echo.Echo) { payRoute := e.Group("/pay") payRoute.GET("/checkout-counter/:trade_id", comm.Ctrl.CheckoutCounter) payRoute.GET("/check-status/:trade_id", comm.Ctrl.CheckStatus) + payRoute.POST("/switch-network", comm.Ctrl.SwitchNetwork) // payment routes paymentRoute := e.Group("/payments") diff --git a/src/static/index.html b/src/static/index.html index 2b4e4e1..71d00f9 100644 --- a/src/static/index.html +++ b/src/static/index.html @@ -1,5 +1,6 @@ +
@@ -13,8 +14,8 @@ @variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)); @theme inline { - --font-sans: "Noto Sans SC Variable", "Noto Sans SC", system-ui, sans-serif; - --font-nunito: "Nunito Variable", "Noto Sans SC Variable", "Noto Sans SC", system-ui, sans-serif; + --font-sans: "Noto Sans SC Variable", "Noto Sans SC", system-ui, sans-serif; + --font-nunito: "Nunito Variable", "Noto Sans SC Variable", "Noto Sans SC", system-ui, sans-serif; --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); @@ -23,249 +24,524 @@ --color-popover-foreground:var(--popover-foreground); --color-primary: var(--primary); --color-primary-foreground:var(--primary-foreground); - --color-primary-hover: var(--primary-hover); --color-secondary: var(--secondary); + --color-secondary-foreground:var(--secondary-foreground); --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground); --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); --color-border: var(--border); - --color-border-hover: var(--border-hover); + --color-input: var(--input); + --color-ring: var(--ring); --color-success: var(--success); --color-warning: var(--warning); - --shadow-card: var(--card-shadow); - --shadow-popover: var(--popover-shadow); --animate-state-in: state-in .28s cubic-bezier(.34,1.56,.64,1); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + } + + /* ── Design tokens ── */ + :root, [data-theme="light"] { + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground:oklch(0.141 0.005 285.823); + --primary: oklch(0.21 0.006 285.885); + --primary-foreground:oklch(0.985 0 0); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground:oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.705 0.015 286.067); + --radius: 0.875rem; + --success: oklch(0.721 0.193 143.56); + --warning: oklch(0.741 0.188 55.68); + } + [data-theme="dark"] { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground:oklch(0.985 0 0); + --primary: oklch(0.92 0.004 286.32); + --primary-foreground:oklch(0.21 0.006 285.885); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground:oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.552 0.016 285.938); + --success: oklch(0.747 0.201 143.5); + --warning: oklch(0.762 0.182 61.47); + } + + /* ── Ambient blobs ── */ + @keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } + } + + @keyframes fill-bar { + from { width: 0%; } + to { width: 100%; } + } + + @keyframes slide-out-l { + from { transform: translateX(0); } + to { transform: translateX(calc(-100% - 20px)); } + } + @keyframes slide-out-r { + from { transform: translateX(0); } + to { transform: translateX(calc(100% + 20px)); } + } + @keyframes slide-in-r { + from { transform: translateX(calc(100% + 20px)); } + to { transform: translateX(0); } + } + @keyframes slide-in-l { + from { transform: translateX(calc(-100% - 20px)); } + to { transform: translateX(0); } + } + + @keyframes blob-drift { + 0% { transform: translate(0,0) scale(1); } + 50% { transform: translate(30px,-20px) scale(1.06); } + 100% { transform: translate(-20px,30px) scale(0.96); } + } + + @layer components { + /* Card */ + .card { + @apply bg-card border border-border rounded-2xl shadow-md transition-colors duration-300; + } + + /* Chip / pill button */ + .chip { + @apply bg-card border border-border rounded-full px-3.5 py-1.5 text-card-foreground shadow-xs transition-all; + } + .chip:hover { @apply bg-accent border-ring; } + .chip:active { @apply opacity-60; } + .chip.w-9 { @apply p-0; } + + /* Copy rows */ + .row { + @apply flex items-center px-4 py-3.5 transition-colors; + } + .row:active { @apply bg-muted; } + + /* Icon button */ + .icon-btn { + @apply flex items-center justify-center size-8 rounded-sm text-muted-foreground bg-secondary border border-transparent shrink-0 transition-colors; + } + .icon-btn:hover { @apply bg-accent text-card-foreground border-border; } + + /* Primary button */ + .btn-primary { + @apply flex items-center justify-center bg-primary text-primary-foreground border border-border rounded-xl px-6 py-3 text-base font-semibold tracking-tight cursor-pointer shadow-md transition-[background,border-color,transform,opacity]; + } + .btn-primary:hover { @apply bg-primary/90; } + .btn-primary:active { @apply scale-[0.97] opacity-90; } + .btn-primary:disabled { @apply opacity-45 cursor-not-allowed scale-100; } + + /* Secondary button */ + .btn-secondary { + @apply inline-flex items-center justify-center bg-card text-card-foreground border border-border rounded-xl px-7 py-3 text-base font-medium cursor-pointer shadow-sm transition-all; + } + .btn-secondary:hover { @apply bg-accent border-ring; } + .btn-secondary:active { @apply opacity-70; } + + /* State icon circle */ + .state-icon { + @apply size-20 rounded-full flex items-center justify-center mx-auto; + } + + /* Dropdown menu */ + .menu { + @apply bg-popover border border-border rounded-xl shadow-xl overflow-hidden; + opacity: 0; + transform: translateY(-8px); + pointer-events: none; + transition: opacity 0.2s, transform 0.2s; + } + .menu--up { transform: translateY(8px); } + .menu.is-open { opacity: 1; transform: translateY(0); pointer-events: all; } + .menu-item { + @apply flex items-center gap-2.5 px-4 py-2.5 text-sm font-medium text-card-foreground cursor-pointer transition-colors; + } + .menu-item:hover { @apply bg-accent; } + .menu-item.is-selected { @apply text-primary font-semibold; } + .menu-item + .menu-item { @apply border-t border-border; } + .select-trigger.is-open { opacity: 0.8; } + .select-trigger.is-open .select-chevron { transform: rotate(180deg); } + + /* QR wrapper */ + .qr-wrapper { + @apply bg-white rounded-xl p-3.5 border border-border shadow-md inline-block; + } + + /* Timer SVG rings */ + .timer-ring-track { stroke: var(--border); transition: stroke .3s; } + .timer-ring-progress { transform: rotate(-90deg); transform-origin: 50% 50%; transition: stroke-dashoffset 1s linear, stroke .5s; } + .timer-icon { @apply text-muted-foreground; } + + /* Flippable status card */ + .status-card { + position: relative; + perspective: 1600px; + transition: height 0.38s cubic-bezier(.22,1,.36,1); + } + .status-card-inner { + position: relative; width: 100%; + transform-style: preserve-3d; + transition: transform 0.82s cubic-bezier(.22,.9,.24,1); + } + .status-card.is-flipped .status-card-inner { transform: rotateY(180deg); } + .status-face { + @apply absolute inset-0 w-full; + -webkit-backface-visibility: hidden; + backface-visibility: hidden; + transform-style: preserve-3d; + } + .status-face-front { z-index: 2; } + .status-face-back { transform: rotateY(180deg); } + .state-panel-shell { @apply pt-px; } + .state-screen { @apply hidden; } + .state-screen.is-active { @apply flex; } + } + + /* ── Panel viewport: hide scrollbars ── */ + #panel-viewport { scrollbar-width: none; } + #panel-viewport::-webkit-scrollbar { display: none; } + + /* ── Toast ── */ + #toast { + @apply fixed left-1/2 bottom-9 text-sm font-medium px-5 py-2.5 rounded-xl border border-border shadow-xl opacity-0 pointer-events-none whitespace-nowrap z-9999; + @apply bg-foreground/95 text-background; + @apply dark:bg-card/95 dark:text-card-foreground; + transform: translateX(-50%) translateY(12px); + transition: opacity 0.22s, transform 0.22s; + } + + @keyframes state-in { + from { opacity: 0; transform: translateY(12px) scale(0.96); } + to { opacity: 1; transform: translateY(0) scale(1); } + } + + ::view-transition-old(root), ::view-transition-new(root) { animation: none; mix-blend-mode: normal; } + ::view-transition-old(root) { z-index: 1; } + ::view-transition-new(root) { z-index: 9; } + + @media (prefers-reduced-motion: reduce) { + .status-card, .status-card-inner, .status-face, .timer-ring-progress { + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + } } - - - - - - -Amount to pay
-Scan or copy address to pay
---:--
-Amount to pay
---
-Payment address
---
-Payment Successful
-Redirecting…
- -Payment Expired
-Please initiate a new payment
-Connection Timeout
-Unable to connect to the payment server
-Order Not Found
-The order does not exist or has already expired
-Network
+Currency
+