feat: get support chain api
This commit is contained in:
@@ -64,3 +64,8 @@ tron_grid_api_key=
|
|||||||
solana_rpc_url=
|
solana_rpc_url=
|
||||||
|
|
||||||
ethereum_ws_url=wss://ethereum.publicnode.com
|
ethereum_ws_url=wss://ethereum.publicnode.com
|
||||||
|
|
||||||
|
|
||||||
|
# epay
|
||||||
|
epay_pid=
|
||||||
|
epay_key=
|
||||||
|
|||||||
@@ -340,3 +340,11 @@ func GetSolanaRpcUrl() string {
|
|||||||
func GetEthereumWsUrl() string {
|
func GetEthereumWsUrl() string {
|
||||||
return strings.TrimSpace(viper.GetString("ethereum_ws_url"))
|
return strings.TrimSpace(viper.GetString("ethereum_ws_url"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetEpayPid() int {
|
||||||
|
return viper.GetInt("epay_pid")
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetEpayKey() string {
|
||||||
|
return viper.GetString("epay_key")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package comm
|
package comm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/request"
|
"github.com/assimon/luuu/model/request"
|
||||||
"github.com/assimon/luuu/model/service"
|
"github.com/assimon/luuu/model/service"
|
||||||
"github.com/assimon/luuu/util/constant"
|
"github.com/assimon/luuu/util/constant"
|
||||||
@@ -22,3 +26,53 @@ func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
|
|||||||
}
|
}
|
||||||
return c.SucJson(ctx, resp)
|
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 {
|
||||||
|
log.Println("bind request error:", err)
|
||||||
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
|
}
|
||||||
|
if err = c.ValidateStruct(ctx, req); err != nil {
|
||||||
|
log.Println("validate request error:", err)
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
resp, err := service.CreateTransaction(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("create transaction error:", err)
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("create transaction response: %+v\n", resp)
|
||||||
|
|
||||||
|
tradeID := resp.TradeId
|
||||||
|
|
||||||
|
ctx.Redirect(302, "/pay/checkout-counter/"+tradeID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package comm
|
package comm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -30,7 +32,13 @@ func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return ctx.String(http.StatusOK, err.Error())
|
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)
|
return tmpl.Execute(ctx.Response(), resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,97 @@ func UpdateOrderIsExpirationById(id uint64, expirationCutoff time.Time) (bool, e
|
|||||||
return result.RowsAffected > 0, result.Error
|
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.
|
// GetTradeIdByWalletAddressAndAmountAndToken resolves the reserved trade id by network, address, token and amount.
|
||||||
func GetTradeIdByWalletAddressAndAmountAndToken(network string, address string, token string, amount float64) (string, error) {
|
func GetTradeIdByWalletAddressAndAmountAndToken(network string, address string, token string, amount float64) (string, error) {
|
||||||
scaledAmount, _ := normalizeLockAmount(amount)
|
scaledAmount, _ := normalizeLockAmount(amount)
|
||||||
|
|||||||
@@ -8,9 +8,14 @@ const (
|
|||||||
CallBackConfirmNo = 2
|
CallBackConfirmNo = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
PaymentTypeEpay = "Epay"
|
||||||
|
)
|
||||||
|
|
||||||
type Orders struct {
|
type Orders struct {
|
||||||
TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id"`
|
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"`
|
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"`
|
BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id"`
|
||||||
Amount float64 `gorm:"column:amount" json:"amount"`
|
Amount float64 `gorm:"column:amount" json:"amount"`
|
||||||
Currency string `gorm:"column:currency" json:"currency"`
|
Currency string `gorm:"column:currency" json:"currency"`
|
||||||
@@ -21,8 +26,11 @@ type Orders struct {
|
|||||||
Status int `gorm:"column:status;default:1" json:"status"`
|
Status int `gorm:"column:status;default:1" json:"status"`
|
||||||
NotifyUrl string `gorm:"column:notify_url" json:"notify_url"`
|
NotifyUrl string `gorm:"column:notify_url" json:"notify_url"`
|
||||||
RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url"`
|
RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url"`
|
||||||
|
Name string `gorm:"column:name" json:"name"`
|
||||||
CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num"`
|
CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num"`
|
||||||
CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm"`
|
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
|
BaseModel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const (
|
|||||||
const (
|
const (
|
||||||
NetworkTron = "tron"
|
NetworkTron = "tron"
|
||||||
NetworkSolana = "solana"
|
NetworkSolana = "solana"
|
||||||
NetworkEthereum = "eth"
|
NetworkEthereum = "ethereum"
|
||||||
NetworkBsc = "bsc"
|
NetworkBsc = "bsc"
|
||||||
NetworkPolygon = "polygon"
|
NetworkPolygon = "polygon"
|
||||||
NetworkPlasma = "plasma"
|
NetworkPlasma = "plasma"
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ type CreateTransactionRequest struct {
|
|||||||
NotifyUrl string `json:"notify_url" validate:"required"`
|
NotifyUrl string `json:"notify_url" validate:"required"`
|
||||||
Signature string `json:"signature" validate:"required"`
|
Signature string `json:"signature" validate:"required"`
|
||||||
RedirectUrl string `json:"redirect_url"`
|
RedirectUrl string `json:"redirect_url"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
PaymentType string `json:"payment_type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r CreateTransactionRequest) Translates() map[string]string {
|
func (r CreateTransactionRequest) Translates() map[string]string {
|
||||||
@@ -36,3 +38,18 @@ type OrderProcessingRequest struct {
|
|||||||
TradeId string
|
TradeId string
|
||||||
BlockTransactionId 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": "网络",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,3 +25,16 @@ type OrderNotifyResponse struct {
|
|||||||
Signature string `json:"signature"` // 签名
|
Signature string `json:"signature"` // 签名
|
||||||
Status int `json:"status"` // 1:等待支付,2:支付成功,3:已过期
|
Status int `json:"status"` // 1:等待支付,2:支付成功,3:已过期
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OrderNotifyResponseEpay epay订单异步回调结构体
|
||||||
|
type OrderNotifyResponseEpay struct {
|
||||||
|
PID int `json:"pid"` // 商户ID
|
||||||
|
TradeNo string `json:"trade_no"` // 平台订单号
|
||||||
|
OutTradeNo string `json:"out_trade_no"` // 商户订单号
|
||||||
|
Type string `json:"type"` // 订单类型
|
||||||
|
Name string `json:"name"` // 商品名称
|
||||||
|
Money string `json:"money"` // 订单金额,保留4位小数
|
||||||
|
Sign string `json:"sign"` // 签名
|
||||||
|
SignType string `json:"sign_type"` // 签名类型 // MD5
|
||||||
|
TradeStatus string `json:"trade_status"` // 订单状态 // only has "TRADE_SUCCESS" for now
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type CheckoutCounterResponse struct {
|
|||||||
ExpirationTime int64 `json:"expiration_time"` // 过期时间 时间戳
|
ExpirationTime int64 `json:"expiration_time"` // 过期时间 时间戳
|
||||||
RedirectUrl string `json:"redirect_url"`
|
RedirectUrl string `json:"redirect_url"`
|
||||||
CreatedAt int64 `json:"created_at"` // 订单创建时间 时间戳
|
CreatedAt int64 `json:"created_at"` // 订单创建时间 时间戳
|
||||||
|
IsSelected bool `json:"is_selected"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CheckStatusResponse struct {
|
type CheckStatusResponse struct {
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
|
|||||||
Status: mdb.StatusWaitPay,
|
Status: mdb.StatusWaitPay,
|
||||||
NotifyUrl: req.NotifyUrl,
|
NotifyUrl: req.NotifyUrl,
|
||||||
RedirectUrl: req.RedirectUrl,
|
RedirectUrl: req.RedirectUrl,
|
||||||
|
Name: req.Name,
|
||||||
|
PaymentType: req.PaymentType,
|
||||||
}
|
}
|
||||||
if err = data.CreateOrderWithTransaction(tx, order); err != nil {
|
if err = data.CreateOrderWithTransaction(tx, order); err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
@@ -155,6 +157,69 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
|
|||||||
if err = data.UnLockTransaction(req.Network, req.ReceiveAddress, req.Token, req.Amount); err != nil {
|
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)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,3 +277,141 @@ func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) {
|
|||||||
}
|
}
|
||||||
return order, nil
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterRespo
|
|||||||
ExpirationTime: orderInfo.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
|
ExpirationTime: orderInfo.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
|
||||||
RedirectUrl: orderInfo.RedirectUrl,
|
RedirectUrl: orderInfo.RedirectUrl,
|
||||||
CreatedAt: orderInfo.CreatedAt.TimestampMilli(),
|
CreatedAt: orderInfo.CreatedAt.TimestampMilli(),
|
||||||
|
IsSelected: orderInfo.IsSelected,
|
||||||
}
|
}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+81
-28
@@ -2,7 +2,10 @@ package mq
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -162,36 +165,86 @@ func processCallback(tradeID string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sendOrderCallback(order *mdb.Orders) error {
|
func sendOrderCallback(order *mdb.Orders) error {
|
||||||
client := http_client.GetHttpClient()
|
|
||||||
orderResp := response.OrderNotifyResponse{
|
|
||||||
TradeId: order.TradeId,
|
|
||||||
OrderId: order.OrderId,
|
|
||||||
Amount: order.Amount,
|
|
||||||
ActualAmount: order.ActualAmount,
|
|
||||||
ReceiveAddress: order.ReceiveAddress,
|
|
||||||
Token: order.Token,
|
|
||||||
BlockTransactionId: order.BlockTransactionId,
|
|
||||||
Status: mdb.StatusPaySuccess,
|
|
||||||
}
|
|
||||||
signature, err := sign.Get(orderResp, config.GetApiAuthToken())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
orderResp.Signature = signature
|
|
||||||
|
|
||||||
resp, err := client.R().
|
switch order.PaymentType {
|
||||||
SetHeader("powered-by", "Epusdt(https://github.com/GMwalletApp/epusdt)").
|
case mdb.PaymentTypeEpay:
|
||||||
SetBody(orderResp).
|
// 构造 EPay 标准回调参数
|
||||||
Post(order.NotifyUrl)
|
notifyData := response.OrderNotifyResponseEpay{
|
||||||
if err != nil {
|
PID: config.GetEpayPid(),
|
||||||
return err
|
TradeNo: order.TradeId, // epusdt 订单号作为 EPay 平台订单号
|
||||||
}
|
OutTradeNo: order.OrderId, // 注意:EPay 回调要求商户订单号使用 out_trade_no 参数
|
||||||
if resp.StatusCode() != http.StatusOK {
|
|
||||||
return errors.New(resp.Status())
|
Type: "alipay",
|
||||||
}
|
Name: order.Name,
|
||||||
if string(resp.Body()) != "ok" {
|
Money: fmt.Sprintf("%.4f", order.Amount),
|
||||||
return errors.New("not ok")
|
TradeStatus: "TRADE_SUCCESS",
|
||||||
|
}
|
||||||
|
|
||||||
|
signstr2, err := sign.Get(notifyData, config.GetEpayKey())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 form-encoded POST(EPay 标准协议格式)
|
||||||
|
formData := url.Values{
|
||||||
|
"pid": {fmt.Sprintf("%d", notifyData.PID)},
|
||||||
|
"trade_no": {notifyData.TradeNo},
|
||||||
|
"out_trade_no": {notifyData.OutTradeNo},
|
||||||
|
"type": {notifyData.Type},
|
||||||
|
"name": {notifyData.Name},
|
||||||
|
"money": {notifyData.Money},
|
||||||
|
"trade_status": {notifyData.TradeStatus},
|
||||||
|
"sign": {signstr2},
|
||||||
|
"sign_type": {"MD5"},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.PostForm(order.NotifyUrl, formData)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
responseBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("notify_url response status: %d, body: %s\n", resp.StatusCode, string(responseBody))
|
||||||
|
|
||||||
|
default:
|
||||||
|
|
||||||
|
client := http_client.GetHttpClient()
|
||||||
|
orderResp := response.OrderNotifyResponse{
|
||||||
|
TradeId: order.TradeId,
|
||||||
|
OrderId: order.OrderId,
|
||||||
|
Amount: order.Amount,
|
||||||
|
ActualAmount: order.ActualAmount,
|
||||||
|
ReceiveAddress: order.ReceiveAddress,
|
||||||
|
Token: order.Token,
|
||||||
|
BlockTransactionId: order.BlockTransactionId,
|
||||||
|
Status: mdb.StatusPaySuccess,
|
||||||
|
}
|
||||||
|
signature, err := sign.Get(orderResp, config.GetApiAuthToken())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
orderResp.Signature = signature
|
||||||
|
|
||||||
|
resp, err := client.R().
|
||||||
|
SetHeader("powered-by", "Epusdt(https://github.com/GMwalletApp/epusdt)").
|
||||||
|
SetBody(orderResp).
|
||||||
|
Post(order.NotifyUrl)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.StatusCode() != http.StatusOK {
|
||||||
|
return errors.New(resp.Status())
|
||||||
|
}
|
||||||
|
if string(resp.Body()) != "ok" {
|
||||||
|
return errors.New("not ok")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,17 @@ package route
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/assimon/luuu/config"
|
||||||
"github.com/assimon/luuu/controller/comm"
|
"github.com/assimon/luuu/controller/comm"
|
||||||
"github.com/assimon/luuu/middleware"
|
"github.com/assimon/luuu/middleware"
|
||||||
|
"github.com/assimon/luuu/model/mdb"
|
||||||
|
"github.com/assimon/luuu/util/constant"
|
||||||
|
"github.com/assimon/luuu/util/sign"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,6 +26,7 @@ func RegisterRoute(e *echo.Echo) {
|
|||||||
payRoute := e.Group("/pay")
|
payRoute := e.Group("/pay")
|
||||||
payRoute.GET("/checkout-counter/:trade_id", comm.Ctrl.CheckoutCounter)
|
payRoute.GET("/checkout-counter/:trade_id", comm.Ctrl.CheckoutCounter)
|
||||||
payRoute.GET("/check-status/:trade_id", comm.Ctrl.CheckStatus)
|
payRoute.GET("/check-status/:trade_id", comm.Ctrl.CheckStatus)
|
||||||
|
payRoute.POST("/switch-network", comm.Ctrl.SwitchNetwork)
|
||||||
|
|
||||||
// payment routes
|
// payment routes
|
||||||
paymentRoute := e.Group("/payments")
|
paymentRoute := e.Group("/payments")
|
||||||
@@ -49,6 +56,7 @@ func RegisterRoute(e *echo.Echo) {
|
|||||||
return comm.Ctrl.FailJson(ctx, err)
|
return comm.Ctrl.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
ctx.Request().Body = io.NopCloser(bytes.NewBuffer(jsonBytes))
|
ctx.Request().Body = io.NopCloser(bytes.NewBuffer(jsonBytes))
|
||||||
|
ctx.Request().ContentLength = int64(len(jsonBytes))
|
||||||
|
|
||||||
return comm.Ctrl.CreateTransaction(ctx)
|
return comm.Ctrl.CreateTransaction(ctx)
|
||||||
}, middleware.CheckApiSign())
|
}, middleware.CheckApiSign())
|
||||||
@@ -70,4 +78,80 @@ func RegisterRoute(e *echo.Echo) {
|
|||||||
walletV1.GET("/:id", comm.Ctrl.GetWallet)
|
walletV1.GET("/:id", comm.Ctrl.GetWallet)
|
||||||
walletV1.POST("/:id/status", comm.Ctrl.ChangeWalletStatus)
|
walletV1.POST("/:id/status", comm.Ctrl.ChangeWalletStatus)
|
||||||
walletV1.POST("/:id/delete", comm.Ctrl.DeleteWallet)
|
walletV1.POST("/:id/delete", comm.Ctrl.DeleteWallet)
|
||||||
|
|
||||||
|
// epay v1 routes
|
||||||
|
epayV1 := paymentRoute.Group("/epay/v1")
|
||||||
|
epayV1.Match([]string{http.MethodPost, http.MethodGet}, "/order/create-transaction/submit.php", func(ctx echo.Context) error {
|
||||||
|
params := make(map[string]interface{})
|
||||||
|
for k, v := range ctx.QueryParams() {
|
||||||
|
params[k] = v[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
signstr := params["sign"].(string)
|
||||||
|
delete(params, "sign")
|
||||||
|
delete(params, "sign_type")
|
||||||
|
|
||||||
|
// we need to add pid to params for signature verification
|
||||||
|
params["pid"] = config.GetEpayPid()
|
||||||
|
|
||||||
|
checkSignature, err := sign.Get(params, config.GetApiAuthToken())
|
||||||
|
if err != nil {
|
||||||
|
return constant.SignatureErr
|
||||||
|
}
|
||||||
|
if checkSignature != signstr {
|
||||||
|
return constant.SignatureErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// safely get string value from map
|
||||||
|
getString := func(m map[string]interface{}, key string) string {
|
||||||
|
v, ok := m[key]
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
money := getString(params, "money")
|
||||||
|
name := getString(params, "name")
|
||||||
|
notifyURL := getString(params, "notify_url")
|
||||||
|
outTradeNo := getString(params, "out_trade_no")
|
||||||
|
returnURL := getString(params, "return_url")
|
||||||
|
|
||||||
|
amountFloat, err := strconv.ParseFloat(money, 64)
|
||||||
|
if err != nil {
|
||||||
|
return comm.Ctrl.FailJson(ctx, fmt.Errorf("invalid money value: %s", money))
|
||||||
|
}
|
||||||
|
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"token": "usdt",
|
||||||
|
"currency": "cny",
|
||||||
|
"network": "tron",
|
||||||
|
"amount": amountFloat,
|
||||||
|
"notify_url": notifyURL,
|
||||||
|
"order_id": outTradeNo,
|
||||||
|
"redirect_url": returnURL,
|
||||||
|
"signature": signstr,
|
||||||
|
"name": name,
|
||||||
|
"payment_type": mdb.PaymentTypeEpay,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Set("request_body", body)
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return comm.Ctrl.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Request().Body = io.NopCloser(bytes.NewBuffer(jsonBytes))
|
||||||
|
ctx.Request().ContentLength = int64(len(jsonBytes))
|
||||||
|
ctx.Request().Method = http.MethodPost
|
||||||
|
ctx.Request().Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
return comm.Ctrl.CreateTransactionAndRedirect(ctx)
|
||||||
|
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+476
-200
@@ -1,5 +1,6 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" data-theme="dark">
|
<html lang="en" data-theme="dark">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
@@ -13,8 +14,8 @@
|
|||||||
@variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
@variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--font-sans: "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;
|
--font-nunito: "Nunito Variable", "Noto Sans SC Variable", "Noto Sans SC", system-ui, sans-serif;
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
@@ -23,249 +24,524 @@
|
|||||||
--color-popover-foreground:var(--popover-foreground);
|
--color-popover-foreground:var(--popover-foreground);
|
||||||
--color-primary: var(--primary);
|
--color-primary: var(--primary);
|
||||||
--color-primary-foreground:var(--primary-foreground);
|
--color-primary-foreground:var(--primary-foreground);
|
||||||
--color-primary-hover: var(--primary-hover);
|
|
||||||
--color-secondary: var(--secondary);
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground:var(--secondary-foreground);
|
||||||
--color-muted: var(--muted);
|
--color-muted: var(--muted);
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
--color-accent: var(--accent);
|
--color-accent: var(--accent);
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-border-hover: var(--border-hover);
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
--color-success: var(--success);
|
--color-success: var(--success);
|
||||||
--color-warning: var(--warning);
|
--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);
|
--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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<link rel="stylesheet" href="/static/payment.css" />
|
|
||||||
<script defer src="https://cdn.jsdmirror.com/npm/lucide@latest/dist/umd/lucide.min.js"></script>
|
<script defer src="https://cdn.jsdmirror.com/npm/lucide@latest/dist/umd/lucide.min.js"></script>
|
||||||
<script defer src="https://cdn.jsdmirror.com/npm/qrcodejs@latest/qrcode.min.js"></script>
|
<script defer src="https://cdn.jsdmirror.com/npm/qrcodejs@latest/qrcode.min.js"></script>
|
||||||
<script defer src="https://cdn.jsdmirror.com/npm/clipboard@latest/dist/clipboard.min.js"></script>
|
<script defer src="https://cdn.jsdmirror.com/npm/clipboard@latest/dist/clipboard.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-background text-foreground min-h-screen flex flex-col items-center justify-center font-sans transition-colors duration-300">
|
|
||||||
|
|
||||||
<!-- Ambient background blobs -->
|
|
||||||
<div class="fixed inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
|
||||||
<div class="blob blob-1"></div>
|
|
||||||
<div class="blob blob-2"></div>
|
|
||||||
<div class="blob blob-3"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col items-center w-full max-w-[400px] px-4 py-8 relative z-10">
|
|
||||||
|
|
||||||
|
<body
|
||||||
|
class="bg-background text-foreground min-h-screen flex flex-col items-center font-sans transition-colors duration-300">
|
||||||
<!-- Nav bar -->
|
<!-- Nav bar -->
|
||||||
<div class="w-full flex items-center justify-between mb-6">
|
<header class="w-full max-w-sm px-4 pt-8 pb-6 flex items-center justify-between relative z-10">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<img src="https://www.gmwallet.app/favicon.png" alt="logo" class="h-8 w-8 rounded-[10px] shadow-sm" />
|
<img src="https://www.gmwallet.app/favicon.png" alt="logo" class="h-8 w-8 rounded-sm shadow-sm" />
|
||||||
<span class="text-[17px] font-semibold tracking-[-0.3px] text-card-foreground">GM Pay</span>
|
<span class="text-lg font-semibold tracking-tight text-card-foreground">GM Pay</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<!-- Language pill -->
|
<!-- Language pill -->
|
||||||
<div class="select-wrap relative select-none" id="dd-lang">
|
<div class="select-wrap relative select-none" id="dd-lang">
|
||||||
<div class="select-trigger ios-pill flex items-center gap-1.5 cursor-pointer" onclick="toggleSelect('dd-lang')">
|
<div class="select-trigger chip flex items-center gap-1.5 cursor-pointer" onclick="toggleSelect('dd-lang')">
|
||||||
<span class="text-[13px] font-medium" id="lang-label">EN</span>
|
<span class="text-sm font-medium" id="lang-label">EN</span>
|
||||||
<i data-lucide="chevron-down" class="select-chevron text-muted-foreground shrink-0 transition-transform duration-200" width="11" height="11" stroke-width="2.5"></i>
|
<i data-lucide="chevron-down"
|
||||||
|
class="select-chevron text-muted-foreground shrink-0 transition-transform duration-200" width="11"
|
||||||
|
height="11" stroke-width="2.5"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="select-menu ios-menu absolute z-100 top-[calc(100%+12px)] right-0 min-w-[160px] overflow-hidden opacity-0 -translate-y-2 pointer-events-none transition-all duration-200" id="dd-lang-menu">
|
<div class="select-menu menu absolute z-100 top-[calc(100%+12px)] right-0 min-w-40" id="dd-lang-menu">
|
||||||
<div class="select-option ios-menu-item is-selected" data-lang="en" onclick="setLang('en')">🇺🇸 English</div>
|
<div class="select-option menu-item is-selected" data-lang="en" onclick="setLang('en')">🇺🇸 English
|
||||||
<div class="select-option ios-menu-item" data-lang="zh" onclick="setLang('zh')">🇨🇳 中文</div>
|
</div>
|
||||||
<div class="select-option ios-menu-item" data-lang="ja" onclick="setLang('ja')">🇯🇵 日本語</div>
|
<div class="select-option menu-item" data-lang="zh" onclick="setLang('zh')">🇨🇳 中文</div>
|
||||||
<div class="select-option ios-menu-item" data-lang="ko" onclick="setLang('ko')">🇰🇷 한국어</div>
|
<div class="select-option menu-item" data-lang="ja" onclick="setLang('ja')">🇯🇵 日本語</div>
|
||||||
<div class="select-option ios-menu-item" data-lang="zh-hk" onclick="setLang('zh-hk')">🇭🇰 繁體中文</div>
|
<div class="select-option menu-item" data-lang="ko" onclick="setLang('ko')">🇰🇷 한국어</div>
|
||||||
<div class="select-option ios-menu-item" data-lang="ru" onclick="setLang('ru')">🇷🇺 Русский</div>
|
<div class="select-option menu-item" data-lang="zh-hk" onclick="setLang('zh-hk')">🇭🇰 繁體中文</div>
|
||||||
|
<div class="select-option menu-item" data-lang="ru" onclick="setLang('ru')">🇷🇺 Русский</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Theme toggle -->
|
<!-- Theme toggle -->
|
||||||
<button class="ios-pill w-9 h-9 flex items-center justify-center cursor-pointer" onclick="toggleTheme(event)" title="Toggle theme">
|
<button class="chip w-9 h-9 flex items-center justify-center cursor-pointer" onclick="toggleTheme(event)"
|
||||||
|
title="Toggle theme">
|
||||||
<i data-lucide="moon" id="icon-moon" class="hidden" width="16" height="16" stroke-width="1.8"></i>
|
<i data-lucide="moon" id="icon-moon" class="hidden" width="16" height="16" stroke-width="1.8"></i>
|
||||||
<i data-lucide="sun" id="icon-sun" width="16" height="16" stroke-width="1.8"></i>
|
<i data-lucide="sun" id="icon-sun" width="16" height="16" stroke-width="1.8"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<!-- Ambient background blobs -->
|
||||||
</div>
|
<div class="fixed inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||||
|
<div
|
||||||
<!-- Hero amount card -->
|
class="absolute rounded-full blur-[80px] animate-[blob-drift_18s_ease-in-out_infinite_alternate] size-[420px] -top-30 -left-25 bg-[color-mix(in_srgb,var(--muted)_60%,transparent)]">
|
||||||
<div id="order-info" class="glass-card w-full px-6 pt-6 pb-5 mb-3">
|
|
||||||
<p class="text-[13px] font-medium text-muted-foreground mb-1" data-i18n="amount_to_pay">Amount to pay</p>
|
|
||||||
<div class="flex items-end gap-3 justify-between">
|
|
||||||
<span class="text-[42px] font-bold leading-none tracking-[-0.04em] text-card-foreground font-nunito" id="display-amount">--</span>
|
|
||||||
<button class="ios-icon-btn mb-0.5 shrink-0" id="btn-copy-amount" title="Copy">
|
|
||||||
<i data-lucide="copy" width="15" height="15" stroke-width="2"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1.5 mt-1.5" id="display-network">--</div>
|
|
||||||
<div class="mt-3 pt-3 border-t border-border/50">
|
|
||||||
<table class="w-full text-[13px] text-muted-foreground border-separate border-spacing-y-1">
|
|
||||||
<tbody>
|
|
||||||
<tr id="display-fiat"></tr>
|
|
||||||
<tr id="display-order-id"></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Flippable payment/state card -->
|
|
||||||
<div id="status-card" class="status-card w-full">
|
|
||||||
<div class="status-card-inner">
|
|
||||||
<div class="status-face status-face-front">
|
|
||||||
<div id="payment-panel" class="w-full">
|
|
||||||
|
|
||||||
<!-- QR section -->
|
|
||||||
<div class="glass-card w-full px-5 py-5 mb-3">
|
|
||||||
<!-- Scan title + small ring -->
|
|
||||||
<div class="flex items-center justify-between mb-3">
|
|
||||||
<p class="text-[13px] font-semibold text-card-foreground" data-i18n="scan_title">Scan or copy address to pay</p>
|
|
||||||
<div id="timer-row" class="relative w-10 h-10 shrink-0">
|
|
||||||
<svg class="w-10 h-10 absolute inset-0" viewBox="0 0 48 48">
|
|
||||||
<circle class="timer-ring-track" cx="24" cy="24" r="20" fill="none" stroke-width="3"/>
|
|
||||||
<circle id="ring-track" class="timer-ring-track" cx="24" cy="24" r="20" fill="none" stroke-width="3"/>
|
|
||||||
<circle id="ring" class="timer-ring-progress" cx="24" cy="24" r="20" fill="none" stroke="#34c759" stroke-width="3"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-dasharray="125.66" stroke-dashoffset="0"/>
|
|
||||||
</svg>
|
|
||||||
<div class="absolute inset-0 flex items-center justify-center">
|
|
||||||
<i data-lucide="timer" class="timer-icon" width="18" height="18" stroke-width="1.8"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Big countdown above QR -->
|
|
||||||
<p class="text-[32px] font-bold font-mono leading-none text-success text-center mb-4" id="countdown">--:--</p>
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<div class="qr-wrapper">
|
|
||||||
<div id="qrcode" class="w-44 h-44"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- hidden inline countdown (used by JS, not shown) -->
|
|
||||||
<span id="countdown-inline" class="sr-only"></span>
|
|
||||||
|
|
||||||
<!-- Copy rows -->
|
|
||||||
<div class="glass-card w-full overflow-hidden mb-3">
|
|
||||||
<!-- Amount row -->
|
|
||||||
<div id="copy-amt-box" class="ios-row cursor-pointer select-none">
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<p class="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-0.5" data-i18n="amount_to_pay">Amount to pay</p>
|
|
||||||
<p class="text-[15px] font-semibold text-card-foreground" id="field-amount">--</p>
|
|
||||||
</div>
|
|
||||||
<span class="ios-icon-btn shrink-0 pointer-events-none" id="btn-copy-amt2">
|
|
||||||
<i data-lucide="copy" width="14" height="14" stroke-width="2"></i>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<!-- Divider -->
|
|
||||||
<div class="h-px bg-border/50 mx-4"></div>
|
|
||||||
<!-- Address row -->
|
|
||||||
<div id="copy-addr-box" class="ios-row cursor-pointer select-none">
|
|
||||||
<div class="flex-1 min-w-0 pr-3">
|
|
||||||
<p class="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-0.5" data-i18n="payment_address">Payment address</p>
|
|
||||||
<p class="text-[13px] font-medium text-card-foreground break-all leading-relaxed" id="field-address">--</p>
|
|
||||||
</div>
|
|
||||||
<span class="ios-icon-btn shrink-0 pointer-events-none self-start mt-0.5" id="btn-copy-addr">
|
|
||||||
<i data-lucide="copy" width="14" height="14" stroke-width="2"></i>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- CTA -->
|
|
||||||
<button class="ios-btn-primary w-full mb-3" id="btn-transferred" onclick="handleTransfer()">
|
|
||||||
<span data-i18n="i_have_transferred">I have transferred</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Status -->
|
|
||||||
<div class="flex items-center justify-center gap-1.5 py-1" id="status-row">
|
|
||||||
<i data-lucide="loader-circle" class="animate-spin text-muted-foreground" width="13" height="13" stroke-width="2.2"></i>
|
|
||||||
<span class="text-[12px] text-muted-foreground" id="status-text" data-i18n="checking_blockchain">Checking blockchain</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div
|
||||||
|
class="absolute rounded-full blur-[80px] animate-[blob-drift_18s_ease-in-out_infinite_alternate] [animation-delay:-6s] size-[340px] -bottom-20 -right-15 bg-[color-mix(in_srgb,var(--success)_14%,transparent)]">
|
||||||
<div class="status-face status-face-back">
|
</div>
|
||||||
<div id="state-panel" class="w-full state-panel-shell">
|
<div
|
||||||
<div class="glass-card w-full flex items-center justify-center min-h-[300px] py-10 px-6">
|
class="absolute rounded-full blur-[80px] animate-[blob-drift_18s_ease-in-out_infinite_alternate] [animation-delay:-12s] size-[260px] top-[45%] left-[55%] bg-[color-mix(in_srgb,var(--warning)_10%,transparent)]">
|
||||||
|
|
||||||
<!-- Success -->
|
|
||||||
<div class="animate-state-in state-screen flex flex-col items-center text-center" id="screen-success">
|
|
||||||
<div class="ios-state-icon bg-[rgba(52,199,89,.15)] mb-6">
|
|
||||||
<i data-lucide="check" width="38" height="38" stroke-width="2.2" stroke="#34c759"></i>
|
|
||||||
</div>
|
|
||||||
<p class="text-[20px] font-bold text-card-foreground mb-2" data-i18n="payment_success">Payment Successful</p>
|
|
||||||
<p class="text-[14px] text-muted-foreground mb-6" data-i18n="redirecting">Redirecting…</p>
|
|
||||||
<i data-lucide="loader-circle" class="animate-spin text-muted-foreground" width="22" height="22" stroke-width="2"></i>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Expired -->
|
|
||||||
<div class="animate-state-in state-screen flex flex-col items-center text-center" id="screen-expired">
|
|
||||||
<div class="ios-state-icon bg-[rgba(255,59,48,.12)] mb-6">
|
|
||||||
<i data-lucide="x" width="38" height="38" stroke-width="2.2" stroke="#ff3b30"></i>
|
|
||||||
</div>
|
|
||||||
<p class="text-[20px] font-bold text-card-foreground mb-2" data-i18n="payment_expired">Payment Expired</p>
|
|
||||||
<p class="text-[14px] text-muted-foreground mb-7" data-i18n="expired_sub">Please initiate a new payment</p>
|
|
||||||
<button class="ios-btn-secondary" onclick="goBack()" data-i18n="back">Back</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Timeout -->
|
|
||||||
<div class="animate-state-in state-screen flex flex-col items-center text-center" id="screen-timeout">
|
|
||||||
<div class="ios-state-icon bg-[rgba(255,149,0,.12)] mb-6">
|
|
||||||
<i data-lucide="triangle-alert" width="38" height="38" stroke-width="2.2" stroke="#ff9500"></i>
|
|
||||||
</div>
|
|
||||||
<p class="text-[20px] font-bold text-card-foreground mb-2" data-i18n="network_timeout">Connection Timeout</p>
|
|
||||||
<p class="text-[14px] text-muted-foreground mb-7" data-i18n="timeout_sub">Unable to connect to the payment server</p>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<button class="ios-btn-secondary" onclick="goBack()" data-i18n="back">Back</button>
|
|
||||||
<button class="ios-btn-primary px-7" onclick="retryPolling()" data-i18n="retry">Retry</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Not Found -->
|
|
||||||
<div class="animate-state-in state-screen flex flex-col items-center text-center" id="screen-not-found">
|
|
||||||
<div class="ios-state-icon bg-[rgba(142,142,147,.12)] mb-6">
|
|
||||||
<i data-lucide="file-x" width="38" height="38" stroke-width="2" stroke="#8e8e93"></i>
|
|
||||||
</div>
|
|
||||||
<p class="text-[20px] font-bold text-card-foreground mb-2" data-i18n="order_not_found">Order Not Found</p>
|
|
||||||
<p class="text-[14px] text-muted-foreground" data-i18n="not_found_sub">The order does not exist or has already expired</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
|
<main class="flex-1 flex flex-col items-center justify-center w-full max-w-sm px-5 relative">
|
||||||
|
|
||||||
|
<!-- Steps -->
|
||||||
|
<div id="step-progress" class="flex items-center gap-2 w-full mb-4 select-none">
|
||||||
|
<div id="step-bar-1" class="h-1 flex-1 rounded-full overflow-hidden"
|
||||||
|
style="background:color-mix(in srgb,var(--foreground) 10%,transparent)">
|
||||||
|
<div id="step-fill-1" class="h-full rounded-full" style="width:0%;background:var(--foreground)"></div>
|
||||||
|
</div>
|
||||||
|
<div id="step-bar-2" class="h-1 flex-1 rounded-full overflow-hidden"
|
||||||
|
style="background:color-mix(in srgb,var(--foreground) 10%,transparent)">
|
||||||
|
<div id="step-fill-2" class="h-full rounded-full" style="width:0%;background:var(--foreground)"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Order info -->
|
||||||
|
<aside id="order-info" class="card w-full px-6 pt-6 pb-5 mb-4">
|
||||||
|
<p class="text-sm font-medium text-muted-foreground mb-1" data-i18n="amount_to_pay">Amount to pay</p>
|
||||||
|
<div class="flex items-end gap-3 justify-between">
|
||||||
|
<span class="text-4xl font-bold leading-none tracking-tighter text-card-foreground font-nunito"
|
||||||
|
id="display-amount">--</span>
|
||||||
|
<button class="icon-btn mb-0.5 shrink-0" id="btn-copy-amount" title="Copy">
|
||||||
|
<i data-lucide="copy" width="15" height="15" stroke-width="2"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5 mt-1.5" id="display-network">--</div>
|
||||||
|
<div class="mt-3 pt-3 border-t border-border/50">
|
||||||
|
<table class="w-full text-sm text-muted-foreground border-separate border-spacing-y-1">
|
||||||
|
<tbody>
|
||||||
|
<tr id="display-fiat"></tr>
|
||||||
|
<tr id="display-order-id"></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Panel viewport: clips horizontal slide -->
|
||||||
|
<div class="relative w-full" id="panel-viewport">
|
||||||
|
|
||||||
|
<!-- Step 1: Select coin + network -->
|
||||||
|
<section id="step1-panel" class="w-full mb-4 pb-4">
|
||||||
|
<div class="flex gap-2 mb-4">
|
||||||
|
<!-- Network -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5"
|
||||||
|
data-i18n="network_label">Network</p>
|
||||||
|
<div class="select-wrap relative select-none" id="dd-network">
|
||||||
|
<div
|
||||||
|
class="select-trigger flex items-center justify-between bg-card border border-border rounded-xl px-3.5 py-3 cursor-pointer gap-2 shadow-sm transition-colors h-[50px]"
|
||||||
|
onclick="toggleSelect('dd-network')">
|
||||||
|
<span id="network-label" class="text-sm font-semibold text-card-foreground leading-none"></span>
|
||||||
|
<i data-lucide="chevron-down"
|
||||||
|
class="select-chevron text-muted-foreground shrink-0 transition-transform duration-200" width="13"
|
||||||
|
height="13" stroke-width="2.5"></i>
|
||||||
|
</div>
|
||||||
|
<div class="select-menu menu absolute z-100 top-[calc(100%+8px)] left-0 w-full" id="dd-network-menu">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Currency -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5"
|
||||||
|
data-i18n="currency_label">Currency</p>
|
||||||
|
<div class="select-wrap relative select-none" id="dd-token">
|
||||||
|
<div
|
||||||
|
class="select-trigger flex items-center justify-between bg-card border border-border rounded-xl px-3.5 py-3 cursor-pointer gap-2 shadow-sm transition-colors h-[50px]"
|
||||||
|
onclick="toggleSelect('dd-token')">
|
||||||
|
<span id="token-label" class="text-sm font-semibold text-card-foreground"></span>
|
||||||
|
<i data-lucide="chevron-down"
|
||||||
|
class="select-chevron text-muted-foreground shrink-0 transition-transform duration-200" width="13"
|
||||||
|
height="13" stroke-width="2.5"></i>
|
||||||
|
</div>
|
||||||
|
<div class="select-menu menu absolute z-100 top-[calc(100%+8px)] left-0 w-full" id="dd-token-menu">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="btn-confirm-step1" class="btn-primary w-full" onclick="confirmStep1()"
|
||||||
|
data-i18n="confirm">Confirm</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Payment panel (Step 2) -->
|
||||||
|
<section id="payment-panel" class="w-full pb-4" style="display:none">
|
||||||
|
<!-- QR + address card -->
|
||||||
|
<div class="card w-full px-5 pt-5 pb-0 mb-4 overflow-hidden">
|
||||||
|
<!-- Scan title + timer -->
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<p class="text-sm font-semibold text-card-foreground" data-i18n="scan_title">Scan or copy address to pay
|
||||||
|
</p>
|
||||||
|
<div id="timer-row" class="relative w-10 h-10 shrink-0">
|
||||||
|
<svg class="w-10 h-10 absolute inset-0" viewBox="0 0 48 48">
|
||||||
|
<circle class="timer-ring-track" cx="24" cy="24" r="20" fill="none" stroke-width="3" />
|
||||||
|
<circle id="ring-track" class="timer-ring-track" cx="24" cy="24" r="20" fill="none" stroke-width="3" />
|
||||||
|
<circle id="ring" class="timer-ring-progress" cx="24" cy="24" r="20" fill="none" stroke="var(--success)"
|
||||||
|
stroke-width="3" stroke-linecap="round" stroke-dasharray="125.66" stroke-dashoffset="0" />
|
||||||
|
</svg>
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
|
<i data-lucide="timer" class="timer-icon" width="18" height="18" stroke-width="1.8"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Countdown -->
|
||||||
|
<p class="text-3xl font-bold font-mono leading-none text-success text-center mb-4" id="countdown">--:--</p>
|
||||||
|
<!-- QR code -->
|
||||||
|
<div class="flex justify-center mb-4">
|
||||||
|
<div class="qr-wrapper">
|
||||||
|
<div id="qrcode" class="w-44 h-44"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Address row (edge-to-edge inside card) -->
|
||||||
|
<div class="h-px bg-border/50 -mx-5"></div>
|
||||||
|
<div id="copy-addr-box" class="row cursor-pointer select-none -mx-5">
|
||||||
|
<div class="flex-1 min-w-0 pr-3">
|
||||||
|
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-0.5"
|
||||||
|
data-i18n="payment_address">Payment address</p>
|
||||||
|
<p class="text-sm font-medium text-card-foreground break-all leading-relaxed" id="field-address">--</p>
|
||||||
|
</div>
|
||||||
|
<span class="icon-btn shrink-0 pointer-events-none self-start mt-0.5" id="btn-copy-addr">
|
||||||
|
<i data-lucide="copy" width="14" height="14" stroke-width="2"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span id="countdown-inline" class="sr-only"></span>
|
||||||
|
<!-- Connect Wallet (above I have transferred) -->
|
||||||
|
<button class="btn-secondary w-full mb-3" id="btn-connect-wallet" onclick="connectWallet()">
|
||||||
|
<i data-lucide="wallet" width="16" height="16" stroke-width="1.8" class="mr-1.5 shrink-0"></i>
|
||||||
|
<span data-i18n="connect_wallet">Connect Wallet to Pay</span>
|
||||||
|
</button>
|
||||||
|
<!-- I have transferred -->
|
||||||
|
<button class="btn-primary w-full mb-3" id="btn-transferred" onclick="handleTransfer()">
|
||||||
|
<span data-i18n="i_have_transferred">I have transferred</span>
|
||||||
|
</button>
|
||||||
|
<!-- Status -->
|
||||||
|
<div class="flex items-center justify-center gap-1.5 py-1" id="status-row">
|
||||||
|
<i data-lucide="loader-circle" class="animate-spin text-muted-foreground" width="13" height="13"
|
||||||
|
stroke-width="2.2"></i>
|
||||||
|
<span class="text-xs text-muted-foreground" id="status-text" data-i18n="checking_blockchain">Checking
|
||||||
|
blockchain</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- State: Success -->
|
||||||
|
<section id="screen-success"
|
||||||
|
class="card w-full flex flex-col items-center justify-center text-center py-10 px-6 min-h-96 mb-4" role="status"
|
||||||
|
aria-live="polite" style="display:none">
|
||||||
|
<div class="state-icon bg-success/15 mb-6">
|
||||||
|
<i data-lucide="check" width="38" height="38" stroke-width="2.2" stroke="var(--success)"></i>
|
||||||
|
</div>
|
||||||
|
<p class="text-xl font-bold text-card-foreground mb-2" data-i18n="payment_success">Payment Successful</p>
|
||||||
|
<p class="text-sm text-muted-foreground mb-6" data-i18n="redirecting">Redirecting…</p>
|
||||||
|
<i data-lucide="loader-circle" class="animate-spin text-muted-foreground" width="22" height="22"
|
||||||
|
stroke-width="2"></i>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- State: Expired -->
|
||||||
|
<section id="screen-expired" class="w-full pb-4" role="status" aria-live="polite" style="display:none">
|
||||||
|
<div class="card w-full flex flex-col items-center justify-center text-center py-10 px-6 mb-4">
|
||||||
|
<div class="state-icon bg-destructive/12 mb-6">
|
||||||
|
<i data-lucide="x" width="38" height="38" stroke-width="2.2" stroke="var(--destructive)"></i>
|
||||||
|
</div>
|
||||||
|
<p class="text-xl font-bold text-card-foreground mb-2" data-i18n="payment_expired">Payment Expired</p>
|
||||||
|
<p class="text-sm text-muted-foreground" data-i18n="expired_sub">Please initiate a new payment</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn-secondary w-full" onclick="goBack()" data-i18n="back">Back</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- State: Timeout -->
|
||||||
|
<section id="screen-timeout" class="w-full pb-4" role="status" aria-live="polite" style="display:none">
|
||||||
|
<div class="card w-full flex flex-col items-center justify-center text-center py-10 px-6 mb-4">
|
||||||
|
<div class="state-icon bg-warning/12 mb-6">
|
||||||
|
<i data-lucide="triangle-alert" width="38" height="38" stroke-width="2.2" stroke="var(--warning)"></i>
|
||||||
|
</div>
|
||||||
|
<p class="text-xl font-bold text-card-foreground mb-2" data-i18n="network_timeout">Connection Timeout</p>
|
||||||
|
<p class="text-sm text-muted-foreground" data-i18n="timeout_sub">Unable to connect to the payment server</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button class="btn-secondary flex-1" onclick="goBack()" data-i18n="back">Back</button>
|
||||||
|
<button class="btn-primary flex-1" onclick="retryPolling()" data-i18n="retry">Retry</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- State: Not Found -->
|
||||||
|
<section id="screen-not-found"
|
||||||
|
class="card w-full flex flex-col items-center justify-center text-center py-10 px-6 min-h-96 mb-4" role="status"
|
||||||
|
aria-live="polite" style="display:none">
|
||||||
|
<div class="state-icon bg-muted-foreground/12 mb-6">
|
||||||
|
<i data-lucide="file-x" width="38" height="38" stroke-width="2" stroke="var(--muted-foreground)"></i>
|
||||||
|
</div>
|
||||||
|
<p class="text-xl font-bold text-card-foreground mb-2" data-i18n="order_not_found">Order Not Found</p>
|
||||||
|
<p class="text-sm text-muted-foreground" data-i18n="not_found_sub">The order does not exist or has already
|
||||||
|
expired
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div><!-- /panel viewport -->
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<footer class="flex items-center justify-center gap-2.5 mt-6 text-[12px] text-muted-foreground select-none">
|
<footer class="flex items-center justify-center gap-2.5 pb-6 text-xs text-muted-foreground select-none relative z-10">
|
||||||
<span class="flex items-center gap-1.5">
|
<span class="flex items-center gap-1.5">
|
||||||
Powered by
|
Powered by
|
||||||
<a href="https://www.gmwallet.app" target="_blank" rel="noopener" class="flex items-center gap-1 font-semibold text-card-foreground hover:opacity-70 transition-opacity">
|
<a href="https://www.gmwallet.app" target="_blank" rel="noopener"
|
||||||
<img src="https://www.gmwallet.app/favicon.png" alt="GM Wallet" class="h-3.5 w-3.5 rounded-[3px]" />
|
class="flex items-center gap-1 font-semibold text-card-foreground hover:opacity-70 transition-opacity">
|
||||||
|
<img src="https://www.gmwallet.app/favicon.png" alt="GM Wallet" class="h-3.5 w-3.5 rounded-xs" />
|
||||||
GM Wallet
|
GM Wallet
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
<span class="opacity-30">|</span>
|
<span class="opacity-30">|</span>
|
||||||
<a href="https://github.com/GMwalletApp/epusdt" target="_blank" rel="noopener" class="flex items-center gap-1 hover:opacity-70 transition-opacity">
|
<a href="https://github.com/GMwalletApp" target="_blank" rel="noopener"
|
||||||
|
class="flex items-center gap-1 hover:opacity-70 transition-opacity">
|
||||||
Open source on <span class="font-semibold text-card-foreground">GitHub</span>
|
Open source on <span class="font-semibold text-card-foreground">GitHub</span>
|
||||||
</a>
|
</a>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
</div>
|
<!-- Toast -->
|
||||||
|
<div id="toast">Copied</div>
|
||||||
|
|
||||||
<!-- Toast -->
|
<!-- Order data injected by server (Go template) -->
|
||||||
<div id="toast">Copied</div>
|
<script>
|
||||||
|
var ORDER = {
|
||||||
<!-- Order data injected by server (Go template) -->
|
tradeId: "{{.TradeId}}",
|
||||||
<script>
|
amount: "{{.Amount}}",
|
||||||
var ORDER = {
|
actualAmount: "{{.ActualAmount}}",
|
||||||
tradeId: "{{.TradeId}}",
|
token: "{{.Token}}",
|
||||||
amount: "{{.Amount}}",
|
currency: "{{.Currency}}",
|
||||||
actualAmount: "{{.ActualAmount}}",
|
network: "{{.Network}}",
|
||||||
token: "{{.Token}}",
|
receiveAddress: "{{.ReceiveAddress}}",
|
||||||
currency: "{{.Currency}}",
|
expirationTime: "{{.ExpirationTime}}",
|
||||||
network: "{{.Network}}",
|
redirectUrl: "{{.RedirectUrl}}",
|
||||||
receiveAddress: "{{.ReceiveAddress}}",
|
createdAt: "{{.CreatedAt}}",
|
||||||
expirationTime: "{{.ExpirationTime}}",
|
is_selected: "{{.IsSelected}}"
|
||||||
redirectUrl: "{{.RedirectUrl}}",
|
};
|
||||||
createdAt: "{{.CreatedAt}}"
|
var PAYMENT_OPTIONS = null;
|
||||||
};
|
</script>
|
||||||
</script>
|
<script defer src="/static/payment.js"></script>
|
||||||
<script defer src="/static/payment.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -1,395 +0,0 @@
|
|||||||
/* =============================================
|
|
||||||
iOS 26 Liquid Glass Design System
|
|
||||||
============================================= */
|
|
||||||
|
|
||||||
/* ---------- CSS Variables ---------- */
|
|
||||||
:root, [data-theme="light"] {
|
|
||||||
--background: #f2f2f7;
|
|
||||||
--foreground: #1c1c1e;
|
|
||||||
--card: rgba(255,255,255,0.72);
|
|
||||||
--card-foreground: #1c1c1e;
|
|
||||||
--popover: rgba(255,255,255,0.88);
|
|
||||||
--popover-foreground:#1c1c1e;
|
|
||||||
--primary: #ffffff;
|
|
||||||
--primary-foreground:#1c1c1e;
|
|
||||||
--primary-hover: #e5e5ea;
|
|
||||||
--secondary: rgba(120,120,128,0.12);
|
|
||||||
--muted: rgba(120,120,128,0.08);
|
|
||||||
--muted-foreground: #8e8e93;
|
|
||||||
--accent: rgba(255,255,255,0.12);
|
|
||||||
--accent-foreground: #1c1c1e;
|
|
||||||
--border: rgba(60,60,67,0.12);
|
|
||||||
--border-hover: rgba(60,60,67,0.22);
|
|
||||||
--success: #34c759;
|
|
||||||
--warning: #ff9500;
|
|
||||||
--destructive: #ff3b30;
|
|
||||||
|
|
||||||
--glass-bg: rgba(255,255,255,0.65);
|
|
||||||
--glass-border: rgba(255,255,255,0.8);
|
|
||||||
--glass-shadow: 0 8px 40px rgba(0,0,0,.10), 0 1.5px 0 rgba(255,255,255,.9) inset;
|
|
||||||
--card-shadow: 0 4px 24px rgba(0,0,0,.08);
|
|
||||||
--popover-shadow: 0 12px 40px rgba(0,0,0,.14);
|
|
||||||
|
|
||||||
--blob-1: rgba(200,200,200,0.18);
|
|
||||||
--blob-2: rgba(52,199,89,0.14);
|
|
||||||
--blob-3: rgba(255,149,0,0.10);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] {
|
|
||||||
--background: #000000;
|
|
||||||
--foreground: #f5f5f7;
|
|
||||||
--card: rgba(28,28,30,0.82);
|
|
||||||
--card-foreground: #f5f5f7;
|
|
||||||
--popover: rgba(44,44,46,0.92);
|
|
||||||
--popover-foreground:#e5e5ea;
|
|
||||||
--primary: #ffffff;
|
|
||||||
--primary-foreground:#1c1c1e;
|
|
||||||
--primary-hover: #e5e5ea;
|
|
||||||
--secondary: rgba(120,120,128,0.18);
|
|
||||||
--muted: rgba(120,120,128,0.12);
|
|
||||||
--muted-foreground: #636366;
|
|
||||||
--accent: rgba(255,255,255,0.15);
|
|
||||||
--accent-foreground: #ffffff;
|
|
||||||
--border: rgba(255,255,255,0.1);
|
|
||||||
--border-hover: rgba(255,255,255,0.18);
|
|
||||||
--success: #30d158;
|
|
||||||
--warning: #ff9f0a;
|
|
||||||
--destructive: #ff453a;
|
|
||||||
|
|
||||||
--glass-bg: rgba(28,28,30,0.75);
|
|
||||||
--glass-border: rgba(255,255,255,0.10);
|
|
||||||
--glass-shadow: 0 8px 40px rgba(0,0,0,.55), 0 1px 0 rgba(255,255,255,.06) inset;
|
|
||||||
--card-shadow: 0 4px 32px rgba(0,0,0,.55);
|
|
||||||
--popover-shadow: 0 12px 40px rgba(0,0,0,.5);
|
|
||||||
|
|
||||||
--blob-1: rgba(255,255,255,0.14);
|
|
||||||
--blob-2: rgba(48,209,88,0.10);
|
|
||||||
--blob-3: rgba(255,159,10,0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- Ambient blobs ---------- */
|
|
||||||
.blob {
|
|
||||||
position: absolute;
|
|
||||||
border-radius: 50%;
|
|
||||||
filter: blur(80px);
|
|
||||||
animation: blob-drift 18s ease-in-out infinite alternate;
|
|
||||||
}
|
|
||||||
.blob-1 {
|
|
||||||
width: 420px; height: 420px;
|
|
||||||
background: var(--blob-1);
|
|
||||||
top: -120px; left: -100px;
|
|
||||||
animation-delay: 0s;
|
|
||||||
}
|
|
||||||
.blob-2 {
|
|
||||||
width: 340px; height: 340px;
|
|
||||||
background: var(--blob-2);
|
|
||||||
bottom: -80px; right: -60px;
|
|
||||||
animation-delay: -6s;
|
|
||||||
}
|
|
||||||
.blob-3 {
|
|
||||||
width: 260px; height: 260px;
|
|
||||||
background: var(--blob-3);
|
|
||||||
top: 45%; left: 55%;
|
|
||||||
animation-delay: -12s;
|
|
||||||
}
|
|
||||||
@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); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- Liquid Glass Card ---------- */
|
|
||||||
.glass-card {
|
|
||||||
background: var(--glass-bg);
|
|
||||||
-webkit-backdrop-filter: blur(28px) saturate(1.6);
|
|
||||||
backdrop-filter: blur(28px) saturate(1.6);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
border-radius: 22px;
|
|
||||||
box-shadow: var(--glass-shadow);
|
|
||||||
transition: background 0.3s, border-color 0.3s, box-shadow 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 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 {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
width: 100%;
|
|
||||||
-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 {
|
|
||||||
padding-top: 2px;
|
|
||||||
}
|
|
||||||
.state-screen {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.state-screen.is-active {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- Timer ---------- */
|
|
||||||
.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 {
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- QR wrapper ---------- */
|
|
||||||
.qr-wrapper {
|
|
||||||
background: #ffffff;
|
|
||||||
border-radius: 18px;
|
|
||||||
padding: 14px;
|
|
||||||
box-shadow: 0 2px 16px rgba(0,0,0,.12), 0 0 0 1px rgba(0,0,0,.04);
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- iOS Pill button ---------- */
|
|
||||||
.ios-pill {
|
|
||||||
background: var(--glass-bg);
|
|
||||||
-webkit-backdrop-filter: blur(16px) saturate(1.4);
|
|
||||||
backdrop-filter: blur(16px) saturate(1.4);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
border-radius: 99px;
|
|
||||||
padding: 6px 13px;
|
|
||||||
color: var(--card-foreground);
|
|
||||||
transition: background 0.15s, opacity 0.15s;
|
|
||||||
}
|
|
||||||
.ios-pill:hover { opacity: 0.82; }
|
|
||||||
.ios-pill:active { opacity: 0.6; }
|
|
||||||
.ios-pill.w-9 { padding: 0; justify-content: center; }
|
|
||||||
|
|
||||||
/* ---------- iOS Rows ---------- */
|
|
||||||
.ios-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 14px 16px;
|
|
||||||
transition: background 0.12s;
|
|
||||||
}
|
|
||||||
.ios-row:active { background: var(--muted); }
|
|
||||||
|
|
||||||
/* ---------- Icon button ---------- */
|
|
||||||
.ios-icon-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 32px; height: 32px;
|
|
||||||
border-radius: 10px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
background: var(--secondary);
|
|
||||||
transition: background 0.15s, color 0.15s;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.ios-icon-btn:hover { background: var(--accent); color: var(--primary); }
|
|
||||||
|
|
||||||
/* ---------- Primary CTA button ---------- */
|
|
||||||
.ios-btn-primary {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: var(--primary);
|
|
||||||
color: var(--primary-foreground);
|
|
||||||
border: none;
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 16px 24px;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: -0.2px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s, transform 0.1s, opacity 0.15s;
|
|
||||||
box-shadow: 0 4px 18px rgba(0,0,0,0.12), 0 1px 0 rgba(255,255,255,.2) inset;
|
|
||||||
}
|
|
||||||
[data-theme="dark"] .ios-btn-primary {
|
|
||||||
box-shadow: 0 4px 18px rgba(255,255,255,0.18), 0 1px 0 rgba(255,255,255,.12) inset;
|
|
||||||
}
|
|
||||||
.ios-btn-primary:hover { background: var(--primary-hover); }
|
|
||||||
.ios-btn-primary:active { transform: scale(0.97); opacity: 0.9; }
|
|
||||||
.ios-btn-primary:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
|
|
||||||
|
|
||||||
/* ---------- Secondary button ---------- */
|
|
||||||
.ios-btn-secondary {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: var(--secondary);
|
|
||||||
color: var(--card-foreground);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 13px;
|
|
||||||
padding: 11px 28px;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s, opacity 0.15s;
|
|
||||||
}
|
|
||||||
.ios-btn-secondary:hover { background: var(--accent); }
|
|
||||||
.ios-btn-secondary:active { opacity: 0.7; }
|
|
||||||
|
|
||||||
/* ---------- State icon circle ---------- */
|
|
||||||
.ios-state-icon {
|
|
||||||
width: 76px; height: 76px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- Context Menu ---------- */
|
|
||||||
.ios-menu {
|
|
||||||
background: var(--popover);
|
|
||||||
-webkit-backdrop-filter: blur(30px) saturate(1.8);
|
|
||||||
backdrop-filter: blur(30px) saturate(1.8);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
border-radius: 16px;
|
|
||||||
box-shadow: var(--popover-shadow);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.ios-menu.is-open {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
pointer-events: all;
|
|
||||||
}
|
|
||||||
.ios-menu-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 11px 15px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--card-foreground);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
.ios-menu-item:hover { background: var(--accent); }
|
|
||||||
.ios-menu-item.is-selected { color: var(--primary); font-weight: 600; }
|
|
||||||
.ios-menu-item + .ios-menu-item { border-top: 1px solid var(--border); }
|
|
||||||
|
|
||||||
/* ---------- Select states ---------- */
|
|
||||||
.select-trigger.is-open { opacity: 0.8; }
|
|
||||||
.select-trigger.is-open .select-chevron { transform: rotate(180deg); }
|
|
||||||
|
|
||||||
/* ---------- Toast ---------- */
|
|
||||||
#toast {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 36px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%) translateY(12px);
|
|
||||||
background: rgba(28,28,30,0.88);
|
|
||||||
-webkit-backdrop-filter: blur(20px);
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 10px 20px;
|
|
||||||
border-radius: 20px;
|
|
||||||
border: 1px solid rgba(255,255,255,0.12);
|
|
||||||
box-shadow: 0 4px 20px rgba(0,0,0,.3);
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: opacity 0.22s, transform 0.22s;
|
|
||||||
z-index: 9999;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
[data-theme="light"] #toast {
|
|
||||||
background: rgba(44,44,46,0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- Animations ---------- */
|
|
||||||
@keyframes state-in {
|
|
||||||
from { opacity: 0; transform: translateY(12px) scale(0.96); }
|
|
||||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- View Transition ---------- */
|
|
||||||
::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; }
|
|
||||||
|
|
||||||
/* ---------- Debug toolbar ---------- */
|
|
||||||
#debug-bar {
|
|
||||||
position: fixed; bottom: 20px; right: 20px; z-index: 9999;
|
|
||||||
display: flex; flex-direction: column; align-items: flex-end; gap: 8px;
|
|
||||||
}
|
|
||||||
#debug-toggle {
|
|
||||||
width: 36px; height: 36px; border-radius: 50%; border: none;
|
|
||||||
background: #7c3aed; color: #fff; cursor: pointer;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
box-shadow: 0 2px 12px rgba(124,58,237,.5);
|
|
||||||
transition: background .15s, transform .1s;
|
|
||||||
}
|
|
||||||
#debug-toggle:hover { background: #6d28d9; }
|
|
||||||
#debug-toggle:active { transform: scale(.92); }
|
|
||||||
#debug-panel {
|
|
||||||
display: none; flex-direction: column; gap: 5px;
|
|
||||||
background: rgba(15,15,20,.92);
|
|
||||||
-webkit-backdrop-filter: blur(10px);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
border: 1px solid rgba(124,58,237,.35); border-radius: 14px;
|
|
||||||
padding: 12px; min-width: 150px;
|
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,.5);
|
|
||||||
}
|
|
||||||
#debug-panel.is-open { display: flex; }
|
|
||||||
.debug-label {
|
|
||||||
font-size: 10px; font-weight: 700; color: #a78bfa;
|
|
||||||
letter-spacing: .12em; text-transform: uppercase;
|
|
||||||
margin-bottom: 4px; padding-bottom: 6px;
|
|
||||||
border-bottom: 1px solid rgba(255,255,255,.08);
|
|
||||||
}
|
|
||||||
.debug-btn {
|
|
||||||
background: rgba(255,255,255,.07); color: #d1d5db;
|
|
||||||
border: 1px solid rgba(255,255,255,.1); border-radius: 8px;
|
|
||||||
padding: 6px 10px; font-size: 12px; font-weight: 500;
|
|
||||||
cursor: pointer; text-align: left; font-family: inherit;
|
|
||||||
transition: background .12s, color .12s;
|
|
||||||
}
|
|
||||||
.debug-btn:hover { background: rgba(255,255,255,.14); color: #fff; }
|
|
||||||
.debug-btn--reset:hover { border-color: #60a5fa; color: #93c5fd; }
|
|
||||||
.debug-btn--success:hover { border-color: #34d399; color: #6ee7b7; }
|
|
||||||
.debug-btn--expired:hover { border-color: #f87171; color: #fca5a5; }
|
|
||||||
.debug-btn--timeout:hover { border-color: #fb923c; color: #fdba74; }
|
|
||||||
.debug-btn--urgent:hover { border-color: #facc15; color: #fde68a; }
|
|
||||||
.debug-btn--not-found {
|
|
||||||
background: rgba(142,142,147,.15);
|
|
||||||
color: #8e8e93;
|
|
||||||
}
|
|
||||||
|
|
||||||
@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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+614
-227
File diff suppressed because it is too large
Load Diff
+11
-9
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/assimon/luuu/model/mdb"
|
||||||
"github.com/btcsuite/btcutil/base58"
|
"github.com/btcsuite/btcutil/base58"
|
||||||
|
"github.com/gagliardetto/solana-go"
|
||||||
)
|
)
|
||||||
|
|
||||||
// isValidEthereumAddress 校验 0x + 20 字节十六进制(主网收款)。
|
// isValidEthereumAddress 校验 0x + 20 字节十六进制(主网收款)。
|
||||||
@@ -46,15 +47,6 @@ func isValidTronAddress(addr string) bool {
|
|||||||
return string(checksum) == string(hash2[:4])
|
return string(checksum) == string(hash2[:4])
|
||||||
}
|
}
|
||||||
|
|
||||||
func isValidSolanaAddress(addr string) bool {
|
|
||||||
addr = strings.TrimSpace(addr)
|
|
||||||
if addr == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
decoded := base58.Decode(addr)
|
|
||||||
return len(decoded) == 32
|
|
||||||
}
|
|
||||||
|
|
||||||
func isValidAddressByNetwork(network, addr string) bool {
|
func isValidAddressByNetwork(network, addr string) bool {
|
||||||
switch strings.ToLower(strings.TrimSpace(network)) {
|
switch strings.ToLower(strings.TrimSpace(network)) {
|
||||||
case mdb.NetworkTron:
|
case mdb.NetworkTron:
|
||||||
@@ -76,3 +68,13 @@ func normalizeWalletAddressByNetwork(network, addr string) string {
|
|||||||
return strings.ToLower(addr)
|
return strings.ToLower(addr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isValidSolanaAddress 校验 Solana Base58 地址是否合法(32 字节公钥)。
|
||||||
|
func isValidSolanaAddress(addr string) bool {
|
||||||
|
addr = strings.TrimSpace(addr)
|
||||||
|
if len(addr) < 32 || len(addr) > 44 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := solana.PublicKeyFromBase58(addr)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|||||||
+22
-16
@@ -13,25 +13,31 @@ var Errno = map[int]string{
|
|||||||
10008: "order does not exist",
|
10008: "order does not exist",
|
||||||
10009: "failed to parse request params",
|
10009: "failed to parse request params",
|
||||||
10010: "order status already changed",
|
10010: "order status already changed",
|
||||||
10011: "supported asset already exists",
|
10011: "exceeded maximum sub-order limit",
|
||||||
10012: "supported asset not found",
|
10012: "cannot switch network on a sub-order",
|
||||||
|
10013: "order is not awaiting payment",
|
||||||
|
10014: "supported asset already exists",
|
||||||
|
10015: "supported asset not found",
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
SystemErr = Err(400)
|
SystemErr = Err(400)
|
||||||
SignatureErr = Err(401)
|
SignatureErr = Err(401)
|
||||||
WalletAddressAlreadyExists = Err(10001)
|
WalletAddressAlreadyExists = Err(10001)
|
||||||
OrderAlreadyExists = Err(10002)
|
OrderAlreadyExists = Err(10002)
|
||||||
NotAvailableWalletAddress = Err(10003)
|
NotAvailableWalletAddress = Err(10003)
|
||||||
PayAmountErr = Err(10004)
|
PayAmountErr = Err(10004)
|
||||||
NotAvailableAmountErr = Err(10005)
|
NotAvailableAmountErr = Err(10005)
|
||||||
RateAmountErr = Err(10006)
|
RateAmountErr = Err(10006)
|
||||||
OrderBlockAlreadyProcess = Err(10007)
|
OrderBlockAlreadyProcess = Err(10007)
|
||||||
OrderNotExists = Err(10008)
|
OrderNotExists = Err(10008)
|
||||||
ParamsMarshalErr = Err(10009)
|
ParamsMarshalErr = Err(10009)
|
||||||
OrderStatusConflict = Err(10010)
|
OrderStatusConflict = Err(10010)
|
||||||
SupportedAssetAlreadyExists = Err(10011)
|
SubOrderLimitExceeded = Err(10011)
|
||||||
SupportedAssetNotFound = Err(10012)
|
CannotSwitchSubOrder = Err(10012)
|
||||||
|
OrderNotWaitPay = Err(10013)
|
||||||
|
SupportedAssetAlreadyExists = Err(10014)
|
||||||
|
SupportedAssetNotFound = Err(10015)
|
||||||
)
|
)
|
||||||
|
|
||||||
type RspError struct {
|
type RspError struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user