2024-06-27 09:10:32 +00:00
import { Component , OnInit , OnDestroy , Output , EventEmitter , Input , ChangeDetectorRef , SimpleChanges , HostListener } from '@angular/core' ;
2024-06-30 08:39:32 +00:00
import { Subscription , tap , of , catchError , Observable , switchMap } from 'rxjs' ;
2024-04-13 20:53:19 +09:00
import { ServicesApiServices } from '../../services/services-api.service' ;
import { nextRoundNumber } from '../../shared/common.utils' ;
import { StateService } from '../../services/state.service' ;
2024-04-13 23:07:19 +09:00
import { AudioService } from '../../services/audio.service' ;
2024-06-27 02:02:35 +00:00
import { ETA , EtaService } from '../../services/eta.service' ;
import { Transaction } from '../../interfaces/electrs.interface' ;
import { MiningStats } from '../../services/mining.service' ;
2024-07-01 16:21:47 +09:00
import { IAuth , AuthServiceMempool } from '../../services/auth.service' ;
2024-06-27 09:10:32 +00:00
2024-06-27 12:56:49 +00:00
export type PaymentMethod = 'balance' | 'bitcoin' | 'cashapp' ;
2024-06-27 09:10:32 +00:00
export type AccelerationEstimate = {
hasAccess : boolean ;
txSummary : TxSummary ;
nextBlockFee : number ;
targetFeeRate : number ;
userBalance : number ;
enoughBalance : boolean ;
cost : number ;
mempoolBaseFee : number ;
vsizeFee : number ;
2024-06-27 12:56:49 +00:00
pools : number [ ] ;
2024-07-01 18:18:13 +09:00
availablePaymentMethods : { [ method : string ] : { min : number , max : number } } ;
2024-06-27 09:10:32 +00:00
}
export type TxSummary = {
txid : string ; // txid of the current transaction
effectiveVsize : number ; // Total vsize of the dependency tree
effectiveFee : number ; // Total fee of the dependency tree in sats
ancestorCount : number ; // Number of ancestors
}
export interface RateOption {
fee : number ;
rate : number ;
index : number ;
}
export const MIN_BID_RATIO = 1 ;
export const DEFAULT_BID_RATIO = 2 ;
export const MAX_BID_RATIO = 4 ;
2024-04-13 16:11:49 +09:00
2024-06-29 06:06:11 +00:00
type CheckoutStep = 'quote' | 'summary' | 'checkout' | 'cashapp' | 'processing' | 'paid' ;
2024-06-28 07:02:12 +00:00
2024-04-13 16:11:49 +09:00
@Component ( {
selector : 'app-accelerate-checkout' ,
templateUrl : './accelerate-checkout.component.html' ,
styleUrls : [ './accelerate-checkout.component.scss' ]
} )
export class AccelerateCheckout implements OnInit , OnDestroy {
2024-06-27 02:02:35 +00:00
@Input ( ) tx : Transaction ;
@Input ( ) miningStats : MiningStats ;
@Input ( ) eta : ETA ;
2024-06-30 01:46:11 +00:00
@Input ( ) scrollEvent : boolean ;
2024-06-28 07:02:12 +00:00
@Input ( ) cashappEnabled : boolean = true ;
2024-06-27 09:10:32 +00:00
@Input ( ) advancedEnabled : boolean = false ;
@Input ( ) forceMobile : boolean = false ;
2024-06-30 05:37:51 +00:00
@Input ( ) showDetails : boolean = false ;
2024-06-30 03:43:28 +00:00
@Input ( ) noCTA : boolean = false ;
2024-06-30 05:37:51 +00:00
@Output ( ) hasDetails = new EventEmitter < boolean > ( ) ;
2024-06-27 09:10:32 +00:00
@Output ( ) changeMode = new EventEmitter < boolean > ( ) ;
2024-04-13 20:53:19 +09:00
calculating = true ;
2024-07-01 06:19:11 +00:00
selectedOption : 'wait' | 'accel' ;
2024-04-13 20:53:19 +09:00
error = '' ;
2024-06-27 09:10:32 +00:00
math = Math ;
isMobile : boolean = window . innerWidth <= 767.98 ;
2024-04-13 20:53:19 +09:00
2024-06-28 07:02:12 +00:00
private _step : CheckoutStep = 'summary' ;
2024-06-27 09:10:32 +00:00
simpleMode : boolean = true ;
2024-06-26 18:35:36 +09:00
paymentMethod : 'cashapp' | 'btcpay' ;
2024-07-01 16:21:47 +09:00
authSubscription$ : Subscription ;
auth : IAuth | null = null ;
2024-06-27 09:10:32 +00:00
2024-04-13 20:53:19 +09:00
// accelerator stuff
square : { appId : string , locationId : string } ;
accelerationUUID : string ;
2024-06-27 09:10:32 +00:00
accelerationSubscription : Subscription ;
difficultySubscription : Subscription ;
2024-04-13 20:53:19 +09:00
estimateSubscription : Subscription ;
2024-06-24 02:06:22 +00:00
estimate : AccelerationEstimate ;
2024-04-13 23:07:19 +09:00
maxBidBoost : number ; // sats
2024-04-13 20:53:19 +09:00
cost : number ; // sats
2024-06-24 02:06:22 +00:00
etaInfo$ : Observable < { hashratePercentage : number , ETA : number , acceleratedETA : number } > ;
2024-06-27 09:10:32 +00:00
showSuccess = false ;
hasAncestors : boolean = false ;
minExtraCost = 0 ;
minBidAllowed = 0 ;
maxBidAllowed = 0 ;
defaultBid = 0 ;
userBid = 0 ;
selectFeeRateIndex = 1 ;
maxRateOptions : RateOption [ ] = [ ] ;
2024-04-13 20:53:19 +09:00
// square
2024-04-13 23:07:19 +09:00
loadingCashapp = false ;
2024-04-13 20:53:19 +09:00
cashappSubmit : any ;
payments : any ;
cashAppPay : any ;
cashAppSubscription : Subscription ;
conversionsSubscription : Subscription ;
2024-07-01 18:18:13 +09:00
conversions : any ;
2024-06-26 18:35:36 +09:00
// btcpay
loadingBtcpayInvoice = false ;
invoice = undefined ;
2024-04-13 20:53:19 +09:00
constructor (
2024-06-27 09:10:32 +00:00
public stateService : StateService ,
2024-04-13 20:53:19 +09:00
private servicesApiService : ServicesApiServices ,
2024-06-24 02:06:22 +00:00
private etaService : EtaService ,
2024-04-13 23:07:19 +09:00
private audioService : AudioService ,
2024-07-01 16:21:47 +09:00
private cd : ChangeDetectorRef ,
private authService : AuthServiceMempool
2024-04-13 23:07:19 +09:00
) {
this . accelerationUUID = window . crypto . randomUUID ( ) ;
}
2024-04-13 16:11:49 +09:00
ngOnInit() {
2024-07-01 16:21:47 +09:00
this . authSubscription $ = this . authService . getAuth $ ( ) . subscribe ( ( auth ) = > {
this . auth = auth ;
this . estimate = null ;
2024-07-02 11:21:30 +09:00
this . error = null ;
2024-07-01 16:21:47 +09:00
this . moveToStep ( 'summary' ) ;
} ) ;
this . authService . refreshAuth $ ( ) . subscribe ( ) ;
2024-04-13 20:53:19 +09:00
const urlParams = new URLSearchParams ( window . location . search ) ;
if ( urlParams . get ( 'cash_request_id' ) ) { // Redirected from cashapp
2024-06-28 07:02:12 +00:00
this . moveToStep ( 'processing' ) ;
2024-04-13 23:07:19 +09:00
this . insertSquare ( ) ;
this . setupSquare ( ) ;
2024-06-28 07:02:12 +00:00
} else {
2024-06-29 09:17:08 +00:00
this . moveToStep ( 'summary' ) ;
2024-04-13 20:53:19 +09:00
}
2024-04-13 23:07:19 +09:00
this . servicesApiService . setupSquare $ ( ) . subscribe ( ids = > {
this . square = {
appId : ids.squareAppId ,
locationId : ids.squareLocationId
} ;
} ) ;
2024-07-01 18:18:13 +09:00
this . conversionsSubscription = this . stateService . conversions $ . subscribe (
async ( conversions ) = > {
this . conversions = conversions ;
}
) ;
2024-04-13 16:11:49 +09:00
}
ngOnDestroy() {
2024-04-13 20:53:19 +09:00
if ( this . estimateSubscription ) {
this . estimateSubscription . unsubscribe ( ) ;
}
2024-07-01 16:21:47 +09:00
if ( this . authSubscription $ ) {
this . authSubscription $ . unsubscribe ( ) ;
}
2024-04-13 20:53:19 +09:00
}
2024-06-30 01:46:11 +00:00
ngOnChanges ( changes : SimpleChanges ) : void {
if ( changes . scrollEvent && this . scrollEvent ) {
this . scrollToElement ( 'acceleratePreviewAnchor' , 'start' ) ;
}
}
2024-06-28 07:02:12 +00:00
moveToStep ( step : CheckoutStep ) {
this . _step = step ;
if ( ! this . estimate && [ 'quote' , 'summary' , 'checkout' ] . includes ( this . step ) ) {
this . fetchEstimate ( ) ;
}
if ( this . _step === 'checkout' && this . canPayWithBitcoin ) {
this . loadingBtcpayInvoice = true ;
2024-06-30 10:08:49 +00:00
this . invoice = null ;
2024-06-28 07:02:12 +00:00
this . requestBTCPayInvoice ( ) ;
} else if ( this . _step === 'cashapp' && this . cashappEnabled ) {
this . loadingCashapp = true ;
this . insertSquare ( ) ;
this . setupSquare ( ) ;
}
2024-06-30 05:37:51 +00:00
this . hasDetails . emit ( this . _step === 'quote' ) ;
2024-06-28 07:02:12 +00:00
}
2024-06-30 01:46:11 +00:00
/ * *
* Scroll to element id with or without setTimeout
* /
scrollToElementWithTimeout ( id : string , position : ScrollLogicalPosition , timeout : number = 1000 ) : void {
setTimeout ( ( ) = > {
this . scrollToElement ( id , position ) ;
} , timeout ) ;
}
scrollToElement ( id : string , position : ScrollLogicalPosition ) {
const acceleratePreviewAnchor = document . getElementById ( id ) ;
if ( acceleratePreviewAnchor ) {
this . cd . markForCheck ( ) ;
acceleratePreviewAnchor . scrollIntoView ( {
behavior : 'smooth' ,
inline : position ,
block : position ,
} ) ;
}
}
2024-04-16 16:56:37 +09:00
/ * *
2024-04-13 20:53:19 +09:00
* Accelerator
* /
2024-06-24 02:06:22 +00:00
fetchEstimate() {
2024-04-13 20:53:19 +09:00
if ( this . estimateSubscription ) {
this . estimateSubscription . unsubscribe ( ) ;
}
this . calculating = true ;
2024-06-27 02:02:35 +00:00
this . estimateSubscription = this . servicesApiService . estimate $ ( this . tx . txid ) . pipe (
2024-04-13 20:53:19 +09:00
tap ( ( response ) = > {
if ( response . status === 204 ) {
this . error = ` cannot_accelerate_tx ` ;
} else {
2024-06-24 02:06:22 +00:00
this . estimate = response . body ;
if ( ! this . estimate ) {
2024-04-13 20:53:19 +09:00
this . error = ` cannot_accelerate_tx ` ;
return ;
}
2024-06-27 09:10:32 +00:00
if ( this . estimate . hasAccess === true && this . estimate . userBalance <= 0 ) {
if ( this . isLoggedIn ( ) ) {
this . error = ` not_enough_balance ` ;
}
}
this . hasAncestors = this . estimate . txSummary . ancestorCount > 1 ;
this . etaInfo $ = this . etaService . getProjectedEtaObservable ( this . estimate , this . miningStats ) ;
2024-04-13 20:53:19 +09:00
// Make min extra fee at least 50% of the current tx fee
2024-06-27 09:10:32 +00:00
this . minExtraCost = nextRoundNumber ( Math . max ( this . estimate . cost * 2 , this . estimate . txSummary . effectiveFee ) ) ;
this . maxRateOptions = [ 1 , 2 , 4 ] . map ( ( multiplier , index ) = > {
return {
fee : this.minExtraCost * multiplier ,
rate : ( this . estimate . txSummary . effectiveFee + ( this . minExtraCost * multiplier ) ) / this . estimate . txSummary . effectiveVsize ,
index ,
} ;
} ) ;
this . minBidAllowed = this . minExtraCost * MIN_BID_RATIO ;
this . defaultBid = this . minExtraCost * DEFAULT_BID_RATIO ;
this . maxBidAllowed = this . minExtraCost * MAX_BID_RATIO ;
this . userBid = this . defaultBid ;
if ( this . userBid < this . minBidAllowed ) {
this . userBid = this . minBidAllowed ;
} else if ( this . userBid > this . maxBidAllowed ) {
this . userBid = this . maxBidAllowed ;
}
this . cost = this . userBid + this . estimate . mempoolBaseFee + this . estimate . vsizeFee ;
2024-06-28 13:29:44 +00:00
if ( this . step === 'checkout' && this . canPayWithBitcoin && ! this . loadingBtcpayInvoice ) {
2024-06-28 07:02:12 +00:00
this . loadingBtcpayInvoice = true ;
this . requestBTCPayInvoice ( ) ;
}
2024-06-26 18:35:36 +09:00
this . calculating = false ;
this . cd . markForCheck ( ) ;
2024-04-13 20:53:19 +09:00
}
} ) ,
catchError ( ( response ) = > {
2024-06-27 09:10:32 +00:00
this . estimate = undefined ;
2024-04-13 20:53:19 +09:00
this . error = ` cannot_accelerate_tx ` ;
2024-06-27 09:10:32 +00:00
this . estimateSubscription . unsubscribe ( ) ;
2024-04-13 20:53:19 +09:00
return of ( null ) ;
} )
) . subscribe ( ) ;
}
2024-06-27 09:10:32 +00:00
/ * *
* User changed his bid
* /
setUserBid ( { fee , index } : { fee : number , index : number } ) : void {
if ( this . estimate ) {
this . selectFeeRateIndex = index ;
this . userBid = Math . max ( 0 , fee ) ;
this . cost = this . userBid + this . estimate . mempoolBaseFee + this . estimate . vsizeFee ;
}
}
/ * *
* Account - based acceleration request
* /
accelerateWithMempoolAccount ( ) : void {
2024-07-01 05:45:32 +00:00
if ( ! this . canPay || this . calculating ) {
return ;
}
2024-06-27 09:10:32 +00:00
if ( this . accelerationSubscription ) {
this . accelerationSubscription . unsubscribe ( ) ;
}
this . accelerationSubscription = this . servicesApiService . accelerate $ (
this . tx . txid ,
this . userBid ,
this . accelerationUUID
) . subscribe ( {
next : ( ) = > {
this . audioService . playSound ( 'ascend-chime-cartoon' ) ;
this . showSuccess = true ;
this . estimateSubscription . unsubscribe ( ) ;
2024-06-29 06:06:11 +00:00
this . moveToStep ( 'paid' )
2024-06-27 09:10:32 +00:00
} ,
error : ( response ) = > {
if ( response . status === 403 && response . error === 'not_available' ) {
this . error = 'waitlisted' ;
} else {
this . error = response . error ;
}
}
} ) ;
}
2024-04-13 20:53:19 +09:00
/ * *
* Square
* /
insertSquare ( ) : void {
//@ts-ignore
if ( window . Square ) {
return ;
}
let statsUrl = 'https://sandbox.web.squarecdn.com/v1/square.js' ;
if ( document . location . hostname === 'mempool-staging.fmt.mempool.space' ||
document . location . hostname === 'mempool-staging.va1.mempool.space' ||
document . location . hostname === 'mempool-staging.fra.mempool.space' ||
document . location . hostname === 'mempool-staging.tk7.mempool.space' ||
document . location . hostname === 'mempool.space' ) {
statsUrl = 'https://web.squarecdn.com/v1/square.js' ;
}
( function ( ) {
const d = document , g = d . createElement ( 'script' ) , s = d . getElementsByTagName ( 'script' ) [ 0 ] ;
// @ts-ignore
g . type = 'text/javascript' ; g . src = statsUrl ; s . parentNode . insertBefore ( g , s ) ;
} ) ( ) ;
}
setupSquare() {
const init = ( ) = > {
this . initSquare ( ) ;
} ;
//@ts-ignore
if ( ! window . Square ) {
console . debug ( 'Square.js failed to load properly. Retrying in 1 second.' ) ;
setTimeout ( init , 1000 ) ;
} else {
init ( ) ;
}
}
async initSquare ( ) : Promise < void > {
try {
//@ts-ignore
this . payments = window . Square . payments ( this . square . appId , this . square . locationId )
await this . requestCashAppPayment ( ) ;
} catch ( e ) {
2024-04-16 16:56:37 +09:00
console . debug ( 'Error loading Square Payments' , e ) ;
2024-04-13 20:53:19 +09:00
return ;
}
}
async requestCashAppPayment() {
if ( this . cashAppSubscription ) {
this . cashAppSubscription . unsubscribe ( ) ;
}
if ( this . conversionsSubscription ) {
this . conversionsSubscription . unsubscribe ( ) ;
}
this . conversionsSubscription = this . stateService . conversions $ . subscribe (
async ( conversions ) = > {
2024-07-01 18:18:13 +09:00
this . conversions = conversions ;
2024-04-13 20:53:19 +09:00
if ( this . cashAppPay ) {
2024-04-16 16:56:37 +09:00
this . cashAppPay . destroy ( ) ;
2024-04-13 20:53:19 +09:00
}
const redirectHostname = document . location . hostname === 'localhost' ? ` http://localhost:4200 ` : ` https:// ${ document . location . hostname } ` ;
2024-04-13 23:07:19 +09:00
const costUSD = this . step === 'processing' ? 69.69 : ( this . cost / 100 _000_000 * conversions . USD ) ; // When we're redirected to this component, the payment data is already linked to the payment token, so does not matter what amonut we put in there, therefore it's 69.69
2024-04-13 20:53:19 +09:00
const paymentRequest = this . payments . paymentRequest ( {
countryCode : 'US' ,
currencyCode : 'USD' ,
total : {
amount : costUSD.toString ( ) ,
label : 'Total' ,
pending : true ,
2024-06-27 02:02:35 +00:00
productUrl : ` ${ redirectHostname } /tracker/ ${ this . tx . txid } ` ,
2024-04-13 20:53:19 +09:00
} ,
button : { shape : 'semiround' , size : 'small' , theme : 'light' }
} ) ;
this . cashAppPay = await this . payments . cashAppPay ( paymentRequest , {
2024-06-27 02:02:35 +00:00
redirectURL : ` ${ redirectHostname } /tracker/ ${ this . tx . txid } ` ,
referenceId : ` accelerator- ${ this . tx . txid . substring ( 0 , 15 ) } - ${ Math . round ( new Date ( ) . getTime ( ) / 1000 ) } ` ,
2024-04-13 20:53:19 +09:00
button : { shape : 'semiround' , size : 'small' , theme : 'light' }
} ) ;
2024-04-13 23:07:19 +09:00
2024-06-28 07:02:12 +00:00
if ( this . step === 'cashapp' ) {
2024-04-13 23:07:19 +09:00
await this . cashAppPay . attach ( ` #cash-app-pay ` , { theme : 'light' , size : 'small' , shape : 'semiround' } )
}
this . loadingCashapp = false ;
2024-04-13 20:53:19 +09:00
const that = this ;
this . cashAppPay . addEventListener ( 'ontokenization' , function ( event ) {
const { tokenResult , error } = event . detail ;
if ( error ) {
this . error = error ;
} else if ( tokenResult . status === 'OK' ) {
that . servicesApiService . accelerateWithCashApp $ (
2024-06-27 02:02:35 +00:00
that . tx . txid ,
2024-04-13 20:53:19 +09:00
tokenResult . token ,
tokenResult . details . cashAppPay . cashtag ,
tokenResult . details . cashAppPay . referenceId ,
that . accelerationUUID
) . subscribe ( {
next : ( ) = > {
2024-04-13 23:07:19 +09:00
that . audioService . playSound ( 'ascend-chime-cartoon' ) ;
2024-04-16 16:56:37 +09:00
if ( that . cashAppPay ) {
that . cashAppPay . destroy ( ) ;
}
setTimeout ( ( ) = > {
2024-06-29 06:06:11 +00:00
this . moveToStep ( 'paid' ) ;
2024-04-16 17:02:10 +09:00
if ( window . history . replaceState ) {
const urlParams = new URLSearchParams ( window . location . search ) ;
window . history . replaceState ( null , null , window . location . toString ( ) . replace ( ` ?cash_request_id= ${ urlParams . get ( 'cash_request_id' ) } ` , '' ) ) ;
}
2024-04-16 16:56:37 +09:00
} , 1000 ) ;
2024-04-13 20:53:19 +09:00
} ,
error : ( response ) = > {
if ( response . status === 403 && response . error === 'not_available' ) {
that . error = 'waitlisted' ;
} else {
that . error = response . error ;
2024-04-16 16:56:37 +09:00
setTimeout ( ( ) = > {
2024-04-16 17:02:10 +09:00
// Reset everything by reloading the page :D, can be improved
2024-04-16 16:56:37 +09:00
const urlParams = new URLSearchParams ( window . location . search ) ;
window . location . assign ( window . location . toString ( ) . replace ( ` ?cash_request_id= ${ urlParams . get ( 'cash_request_id' ) } ` , ` ` ) ) ;
} , 3000 ) ;
2024-04-13 20:53:19 +09:00
}
}
} ) ;
}
} ) ;
}
) ;
}
2024-06-26 18:35:36 +09:00
/ * *
* BTCPay
* /
async requestBTCPayInvoice() {
2024-06-30 08:39:32 +00:00
this . servicesApiService . generateBTCPayAcceleratorInvoice $ ( this . tx . txid , this . userBid ) . pipe (
switchMap ( response = > {
return this . servicesApiService . retreiveInvoice $ ( response . btcpayInvoiceId ) ;
} ) ,
catchError ( error = > {
console . log ( error ) ;
return of ( null ) ;
} )
) . subscribe ( ( invoice ) = > {
this . invoice = invoice ;
2024-06-26 18:35:36 +09:00
this . cd . markForCheck ( ) ;
} ) ;
}
2024-06-30 10:04:24 +00:00
bitcoinPaymentCompleted ( ) : void {
this . audioService . playSound ( 'ascend-chime-cartoon' ) ;
this . estimateSubscription . unsubscribe ( ) ;
this . moveToStep ( 'paid' )
}
2024-06-27 09:10:32 +00:00
isLoggedIn ( ) : boolean {
2024-07-01 16:21:47 +09:00
return this . auth !== null ;
2024-06-27 09:10:32 +00:00
}
2024-07-01 06:19:11 +00:00
/ * *
* UI events
* /
selectedOptionChanged ( event ) {
this . selectedOption = event . target . id ;
}
2024-06-28 07:02:12 +00:00
get step() {
return this . _step ;
}
get canPayWithBitcoin() {
2024-07-01 18:18:13 +09:00
const paymentMethod = this . estimate ? . availablePaymentMethods ? . bitcoin ;
return paymentMethod && this . cost >= paymentMethod . min && this . cost <= paymentMethod . max ;
2024-06-28 07:02:12 +00:00
}
get canPayWithCashapp() {
2024-07-01 18:18:13 +09:00
if ( ! this . cashappEnabled || ! this . conversions || this . stateService . referrer !== 'https://cash.app/' ) {
return false ;
}
const paymentMethod = this . estimate ? . availablePaymentMethods ? . cashapp ;
if ( paymentMethod ) {
const costUSD = ( this . cost / 100 _000_000 * this . conversions . USD ) ;
if ( costUSD >= paymentMethod . min && costUSD <= paymentMethod . max ) {
return true ;
}
}
return false ;
2024-06-28 07:02:12 +00:00
}
get canPayWithBalance() {
2024-07-02 12:20:14 +00:00
if ( ! this . hasAccessToBalanceMode ) {
2024-07-01 18:18:13 +09:00
return false ;
}
const paymentMethod = this . estimate ? . availablePaymentMethods ? . balance ;
return paymentMethod && this . cost >= paymentMethod . min && this . cost <= paymentMethod . max ;
2024-06-29 07:04:08 +00:00
}
get canPay() {
return this . canPayWithBalance || this . canPayWithBitcoin || this . canPayWithCashapp ;
2024-06-28 07:02:12 +00:00
}
2024-07-02 12:20:14 +00:00
get hasAccessToBalanceMode() {
return this . isLoggedIn ( ) && this . estimate ? . hasAccess ;
}
2024-06-27 09:10:32 +00:00
@HostListener ( 'window:resize' , [ '$event' ] )
onResize ( ) : void {
this . isMobile = window . innerWidth <= 767.98 ;
}
2024-04-13 16:11:49 +09:00
}