Updated on 2026-08-14
This commit is contained in:
commit
c2b14b6515
11 changed files with 125 additions and 69 deletions
|
|
@ -39,14 +39,14 @@ class HomeFragment : Fragment(), StoreSubscriber<HomeState> {
|
|||
super.onCreate(savedInstanceState)
|
||||
store.dispatch(HomeAction.OnCreate)
|
||||
store.dispatch(HomeAction.Init)
|
||||
if (learn2earnViewModel.uiState.storyScreenState.isVisible) {
|
||||
store.dispatch(HomeAction.InsertStory(position = 0, Stories.OneInchPromo))
|
||||
// re init homeState after inserting learn2earn story
|
||||
homeState = mutableStateOf(store.state.homeState)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
// sync adding story before screen creation
|
||||
if (learn2earnViewModel.uiState.storyScreenState.isVisible) {
|
||||
store.dispatch(HomeAction.InsertStory(position = 0, Stories.OneInchPromo))
|
||||
homeState.value = store.state.homeState
|
||||
}
|
||||
return ComposeView(inflater.context).apply {
|
||||
setContent {
|
||||
TangemTheme {
|
||||
|
|
|
|||
|
|
@ -20,9 +20,7 @@ import com.tangem.tap.common.extensions.eraseContext
|
|||
import com.tangem.tap.common.extensions.onUserWalletSelected
|
||||
import com.tangem.tap.common.redux.AppState
|
||||
import com.tangem.tap.common.redux.global.GlobalAction
|
||||
import com.tangem.tap.features.home.BELARUS_COUNTRY_CODE
|
||||
import com.tangem.tap.features.home.RUSSIA_COUNTRY_CODE
|
||||
import com.tangem.tap.features.home.redux.HomeMiddleware.BUY_WALLET_URL
|
||||
import com.tangem.tap.features.home.redux.HomeMiddleware.NEW_BUY_WALLET_URL
|
||||
import com.tangem.tap.features.send.redux.states.ButtonState
|
||||
import com.tangem.tap.features.signin.redux.SignInAction
|
||||
import com.tangem.tap.preferencesStorage
|
||||
|
|
@ -40,6 +38,7 @@ object HomeMiddleware {
|
|||
val handler = homeMiddleware
|
||||
|
||||
const val BUY_WALLET_URL = "https://tangem.com/ru/resellers/"
|
||||
const val NEW_BUY_WALLET_URL = "https://buy.tangem.com/ru/"
|
||||
}
|
||||
|
||||
private val homeMiddleware: Middleware<AppState> = { _, _ ->
|
||||
|
|
@ -67,10 +66,13 @@ private fun handleHomeAction(action: Action) {
|
|||
}
|
||||
is HomeAction.GoToShop -> {
|
||||
Analytics.send(Shop.ScreenOpened())
|
||||
when (action.userCountryCode) {
|
||||
RUSSIA_COUNTRY_CODE, BELARUS_COUNTRY_CODE -> store.dispatchOpenUrl(BUY_WALLET_URL)
|
||||
else -> store.dispatch(NavigationAction.NavigateTo(AppScreen.Shop))
|
||||
}
|
||||
store.dispatchOpenUrl(NEW_BUY_WALLET_URL)
|
||||
|
||||
// disabled for now in task [REDACTED_JIRA]
|
||||
// when (action.userCountryCode) {
|
||||
// RUSSIA_COUNTRY_CODE, BELARUS_COUNTRY_CODE -> store.dispatchOpenUrl(BUY_WALLET_URL)
|
||||
// else -> store.dispatch(NavigationAction.NavigateTo(AppScreen.Shop))
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -173,7 +173,7 @@
|
|||
<string name="main_no_backup_warning_title">Бэкап кошелька не был произведен</string>
|
||||
<string name="main_page_balance">Баланс</string>
|
||||
<string name="main_processing_full_amount">В сумме учтены не все монеты</string>
|
||||
<string name="main_promotion_credited">1INCH токены будут зачислены на адрес вашего кошелька в сети %s в течение 24 часов</string>
|
||||
<string name="main_promotion_credited">1INCH токены будут зачислены на адрес вашего кошелька в сети %s в течение 48 часов</string>
|
||||
<string name="main_promotion_no_purchase">По вашему промокоду не было покупки кошелька, а значит вы не можете получить бонус. Купите кошелек Tangem, отсканируйте его в приложении и получите бонус.</string>
|
||||
<string name="main_scan_card_warning_view_subtitle">Чтобы получить доступ ко всем сетям, вам необходимо отсканировать карту</string>
|
||||
<string name="main_scan_card_warning_view_title">Отсканируйте карту</string>
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@
|
|||
<string name="main_no_backup_warning_title">Your wallet has not been backed up</string>
|
||||
<string name="main_page_balance">Total balance</string>
|
||||
<string name="main_processing_full_amount">The amount does not include some of your funds</string>
|
||||
<string name="main_promotion_credited">1INCH tokens will be credited to your %s wallet address within 24 hours</string>
|
||||
<string name="main_promotion_credited">1INCH tokens will be credited to your %s wallet address within 48 hours</string>
|
||||
<string name="main_promotion_no_purchase">There was no purchase of a wallet using your promo code, which means you cannot receive a bonus. Buy Tangem wallet, scan it in the app, and get the bonus.</string>
|
||||
<string name="main_scan_card_warning_view_subtitle">To access all the networks you need to scan the card</string>
|
||||
<string name="main_scan_card_warning_view_title">Scan your card</string>
|
||||
|
|
|
|||
|
|
@ -20,13 +20,22 @@ class InputNumberFormatter(
|
|||
val thousandsSeparator = symbols.groupingSeparator
|
||||
val decimalSeparator = symbols.decimalSeparator
|
||||
|
||||
if (text.startsWith("0") && text.length > 1 && text[1] != decimalSeparator) {
|
||||
val lastChar = text.lastOrNull()
|
||||
val trimmedText = if (text.isNotEmpty() && (lastChar == thousandsSeparator || lastChar == POINT_SEPARATOR)) {
|
||||
text.dropLast(1) + decimalSeparator
|
||||
} else {
|
||||
text
|
||||
}
|
||||
|
||||
if (trimmedText.startsWith("0") && trimmedText.length > 1 && trimmedText[1] != decimalSeparator) {
|
||||
return "0"
|
||||
}
|
||||
|
||||
val filteredChars = text.replace(thousandsSeparator.toString(), "").filterIndexed { index, c ->
|
||||
val isOneOrZeroPoint = c == decimalSeparator && index != 0 && text.count { it == decimalSeparator } <= 1
|
||||
val isIndexPointIndex = c == decimalSeparator && index != 0 && text.indexOf(decimalSeparator) == index
|
||||
val filteredChars = trimmedText.replace(thousandsSeparator.toString(), "").filterIndexed { index, c ->
|
||||
val isOneOrZeroPoint =
|
||||
c == decimalSeparator && index != 0 && trimmedText.count { it == decimalSeparator } <= 1
|
||||
val isIndexPointIndex =
|
||||
c == decimalSeparator && index != 0 && trimmedText.indexOf(decimalSeparator) == index
|
||||
c.isDigit() || isIndexPointIndex || isOneOrZeroPoint
|
||||
}
|
||||
// If dot is present, take first digits before decimal and first decimals digits after decimal
|
||||
|
|
@ -64,5 +73,6 @@ class InputNumberFormatter(
|
|||
|
||||
companion object {
|
||||
private const val TEXT_CHUNK_THOUSAND = 3
|
||||
private const val POINT_SEPARATOR = '.'
|
||||
}
|
||||
}
|
||||
|
|
@ -38,7 +38,10 @@ internal sealed class Learn2earnEvents(
|
|||
params = mapOf(AnalyticsParam.CLIENT_TYPE to clientType.value),
|
||||
)
|
||||
|
||||
class NoticeClaimSuccess : MainScreen("Notice - Claim Successed")
|
||||
class NoticeClaimSuccess(clientType: AnalyticsParam.ClientType) : MainScreen(
|
||||
event = "Notice - Successful Claim",
|
||||
params = mapOf(AnalyticsParam.CLIENT_TYPE to clientType.value),
|
||||
)
|
||||
}
|
||||
|
||||
sealed class PromoScreen(
|
||||
|
|
@ -65,6 +68,6 @@ internal sealed class AnalyticsParam {
|
|||
}
|
||||
|
||||
companion object Key {
|
||||
const val CLIENT_TYPE = "Client type"
|
||||
const val CLIENT_TYPE = "Client Type"
|
||||
}
|
||||
}
|
||||
|
|
@ -29,7 +29,7 @@ import timber.log.Timber
|
|||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
@Suppress("LongParameterList", "LargeClass")
|
||||
internal class DefaultLearn2earnInteractor(
|
||||
private val featureToggleManager: Learn2earnFeatureToggleManager,
|
||||
private val repository: Learn2earnRepository,
|
||||
|
|
@ -43,19 +43,22 @@ internal class DefaultLearn2earnInteractor(
|
|||
|
||||
override var webViewResultHandler: WebViewResultHandler? = null
|
||||
|
||||
private val promoUserData: PromoUserData
|
||||
get() = repository.getUserData()
|
||||
|
||||
private lateinit var promotion: Promotion
|
||||
|
||||
private val scope = CoroutineScope(Job() + dispatchers.io + FeatureCoroutineExceptionHandler.create("mainScope"))
|
||||
|
||||
private val isTangemWalletFlow = MutableStateFlow(false)
|
||||
private var isTangemWalletFlowJob: Job? = null
|
||||
private var isTangemWalletFlowSync = false
|
||||
private var isTangemWalletSync = false
|
||||
|
||||
private val webViewUriBuilder: WebViewUriBuilder by lazy {
|
||||
WebViewUriBuilder(
|
||||
authCredentialsProvider = dependencyProvider.getWebViewAuthCredentialsProvider(),
|
||||
localeLanguageProvider = dependencyProvider.getLocaleProvider(),
|
||||
promoCodeProvider = { repository.getUserData().promoCode },
|
||||
promoCodeProvider = { promoUserData.promoCode },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -70,12 +73,13 @@ internal class DefaultLearn2earnInteractor(
|
|||
override fun getIsTangemWalletFlow(): Flow<Boolean> = isTangemWalletFlow
|
||||
|
||||
override fun isUserHadPromoCode(): Boolean {
|
||||
return repository.getUserData().promoCode != null
|
||||
return promoUserData.promoCode != null
|
||||
}
|
||||
|
||||
override suspend fun validateUserWallet(): Result<Unit> {
|
||||
val promoCode = repository.getUserData().promoCode
|
||||
val promoCode = promoUserData.promoCode
|
||||
val userWalletId = userWalletManager.getWalletId()
|
||||
if (userWalletId.isEmpty()) return Result.failure(PromotionError.EmptyUserWalletId)
|
||||
|
||||
val domainError = if (promoCode == null) {
|
||||
repository.validate(userWalletId).error
|
||||
|
|
@ -92,14 +96,13 @@ internal class DefaultLearn2earnInteractor(
|
|||
}
|
||||
|
||||
override fun isUserRegisteredInPromotion(): Boolean {
|
||||
return repository.getUserData().isRegisteredInPromotion
|
||||
return promoUserData.isRegisteredInPromotion
|
||||
}
|
||||
|
||||
override fun getAwardAmount(): Int = try {
|
||||
val promoCode = repository.getUserData().promoCode
|
||||
promotion.getPromotionInfo().getData(promoCode).award.toInt()
|
||||
promotion.getPromotionInfo().getCardData().award.toInt()
|
||||
} catch (ex: NullPointerException) {
|
||||
Timber.e(ex)
|
||||
Timber.w(ex)
|
||||
0
|
||||
}
|
||||
|
||||
|
|
@ -114,7 +117,7 @@ internal class DefaultLearn2earnInteractor(
|
|||
?: return Result.failure(PromotionError.UnknownError("Currency for award is null"))
|
||||
|
||||
val walletId = userWalletManager.getWalletId()
|
||||
val promoCode = repository.getUserData().promoCode
|
||||
val promoCode = promoUserData.promoCode
|
||||
|
||||
val domainError = if (promoCode == null) {
|
||||
requestAward(walletId, awardCurrency)
|
||||
|
|
@ -199,11 +202,11 @@ internal class DefaultLearn2earnInteractor(
|
|||
}
|
||||
|
||||
override fun buildUriForNewUser(): Uri {
|
||||
return webViewUriBuilder.buildUriForNewUser(repository.getUserData().isLearningStageFinished)
|
||||
return webViewUriBuilder.buildUriForNewUser(promoUserData.isLearningStageFinished)
|
||||
}
|
||||
|
||||
override fun buildUriForOldUser(): Uri {
|
||||
return webViewUriBuilder.buildUriForOldUser(repository.getUserData().isLearningStageFinished)
|
||||
return webViewUriBuilder.buildUriForOldUser(promoUserData.isLearningStageFinished)
|
||||
}
|
||||
|
||||
override fun getBasicAuthHeaders(): ArrayList<String> {
|
||||
|
|
@ -251,8 +254,8 @@ internal class DefaultLearn2earnInteractor(
|
|||
val isActive = when {
|
||||
demoModeDatasource.isDemoModeActive -> false
|
||||
!featureToggleManager.isLearn2earnEnabled -> false
|
||||
repository.getUserData().isAlreadyReceivedAward -> false
|
||||
else -> !promotion.isError()
|
||||
promoUserData.isAlreadyReceivedAward -> false
|
||||
else -> promotion.isReadyToUse()
|
||||
}
|
||||
|
||||
return isActive
|
||||
|
|
@ -267,10 +270,18 @@ internal class DefaultLearn2earnInteractor(
|
|||
}
|
||||
|
||||
override fun isPromotionActiveOnMain(): Boolean {
|
||||
return when {
|
||||
!isPromotionActive() -> false
|
||||
!isTangemWalletFlowSync -> false
|
||||
else -> promotion.getPromotionInfo().oldCard.status == PromotionInfoResponse.Status.ACTIVE
|
||||
if (!isPromotionActive() || !isTangemWalletSync) return false
|
||||
|
||||
val promotionInfo = promotion.getPromotionInfo()
|
||||
return if (isUseLogicForOldCards()) {
|
||||
promotionInfo.oldCard.status == PromotionInfoResponse.Status.ACTIVE
|
||||
} else {
|
||||
when (promotionInfo.newCard.status) {
|
||||
PromotionInfoResponse.Status.PENDING -> !promoUserData.isAlreadyReceivedAward
|
||||
PromotionInfoResponse.Status.ACTIVE -> true
|
||||
// disable promotion for a new user only if they have received an award
|
||||
PromotionInfoResponse.Status.FINISHED -> !promoUserData.isAlreadyReceivedAward
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -325,25 +336,35 @@ internal class DefaultLearn2earnInteractor(
|
|||
)
|
||||
}
|
||||
|
||||
private fun updateUserData(updateBlock: (PromoUserData) -> PromoUserData): PromoUserData {
|
||||
return updateBlock(repository.getUserData()).apply {
|
||||
repository.updateUserData(this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun subscribeToCardTypeResolverFlow() {
|
||||
if (isTangemWalletFlowJob == null || isTangemWalletFlowJob?.isCancelled == true) {
|
||||
isTangemWalletFlowJob = dependencyProvider.getCardTypeResolverFlow()
|
||||
.onEach {
|
||||
isTangemWalletFlowSync = it?.isTangemWallet() ?: false
|
||||
isTangemWalletFlow.emit(isTangemWalletFlowSync)
|
||||
val isTangemWallet = it?.isTangemWallet() ?: false
|
||||
isTangemWalletSync = isTangemWallet
|
||||
isTangemWalletFlow.emit(isTangemWallet)
|
||||
}
|
||||
.launchIn(scope)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Promotion.PromotionInfo.getData(promoCode: String?): PromotionInfoResponse.Data {
|
||||
return if (promoCode == null) {
|
||||
// region Utils
|
||||
private fun updateUserData(updateBlock: (PromoUserData) -> PromoUserData): PromoUserData {
|
||||
return updateBlock(promoUserData).apply {
|
||||
repository.updateUserData(this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isUseLogicForOldCards(): Boolean {
|
||||
return promoUserData.promoCode == null
|
||||
}
|
||||
|
||||
private fun Promotion.isReadyToUse(): Boolean {
|
||||
return info != null
|
||||
}
|
||||
|
||||
private fun Promotion.PromotionInfo.getCardData(): PromotionInfoResponse.Data {
|
||||
return if (isUseLogicForOldCards()) {
|
||||
oldCard
|
||||
} else {
|
||||
newCard
|
||||
|
|
@ -360,4 +381,5 @@ internal class DefaultLearn2earnInteractor(
|
|||
WebViewResult.Empty -> WebViewAction.PROCEED
|
||||
}
|
||||
}
|
||||
// endregion Utils
|
||||
}
|
||||
|
|
@ -13,8 +13,6 @@ internal data class Promotion(
|
|||
@Throws(NullPointerException::class)
|
||||
fun getPromotionInfo(): PromotionInfo = info!!
|
||||
|
||||
fun isError(): Boolean = error != null
|
||||
|
||||
data class PromotionInfo(
|
||||
val newCard: PromotionInfoResponse.Data,
|
||||
val oldCard: PromotionInfoResponse.Data,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ sealed class PromotionError(val code: Int, val description: String) : Throwable(
|
|||
class ProgramWasEnd(code: Int, description: String) : PromotionError(code, description)
|
||||
|
||||
class UnknownError(description: String) : PromotionError(code = -1, description)
|
||||
object EmptyUserWalletId : PromotionError(code = -1, "UserWalletId is empty")
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
|
|
|
|||
|
|
@ -55,12 +55,10 @@ class Learn2earnViewModel @Inject constructor(
|
|||
viewModelScope.launch(dispatchers.io) {
|
||||
interactor.getIsTangemWalletFlow()
|
||||
.collect { isTangemWallet ->
|
||||
updateUi {
|
||||
uiState
|
||||
.updateGetBonusVisibility(
|
||||
isVisible = isTangemWallet && interactor.isPromotionActiveOnMain(),
|
||||
)
|
||||
.changeGetBonusDescription(getBonusDescription())
|
||||
if (isTangemWallet) {
|
||||
updateMainScreenViews()
|
||||
} else {
|
||||
updateUi { uiState.updateGetBonusVisibility(isVisible = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -103,7 +101,7 @@ class Learn2earnViewModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
.onFailure {
|
||||
Timber.e(it)
|
||||
Timber.w(it)
|
||||
updateUi { uiState.updateGetBonusVisibility(isVisible = false) }
|
||||
}
|
||||
}
|
||||
|
|
@ -119,7 +117,9 @@ class Learn2earnViewModel @Inject constructor(
|
|||
-> {
|
||||
updateUi { uiState.updateViewsVisibility(isVisible = false) }
|
||||
}
|
||||
is PromotionError.CodeWasNotAppliedInShop -> {
|
||||
is PromotionError.CodeWasNotAppliedInShop,
|
||||
is PromotionError.EmptyUserWalletId,
|
||||
-> {
|
||||
updateUi { uiState.updateGetBonusVisibility(isVisible = false) }
|
||||
}
|
||||
is PromotionError.CodeNotFound,
|
||||
|
|
@ -141,11 +141,11 @@ class Learn2earnViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun onButtonMainClick() {
|
||||
if (interactor.isUserHadPromoCode() || interactor.isUserRegisteredInPromotion()) {
|
||||
analytics.send(Learn2earnEvents.MainScreen.NoticeLear2earn(AnalyticsParam.ClientType.Old()))
|
||||
val isUserHasPromo = interactor.isUserHadPromoCode()
|
||||
analytics.send(Learn2earnEvents.MainScreen.NoticeLear2earn(getAnalyticsClientType(isUserHasPromo)))
|
||||
if (isUserHasPromo || interactor.isUserRegisteredInPromotion()) {
|
||||
requestAward()
|
||||
} else {
|
||||
analytics.send(Learn2earnEvents.MainScreen.NoticeLear2earn(AnalyticsParam.ClientType.New()))
|
||||
subscribeToWebViewResultEvents()
|
||||
router.openWebView(interactor.buildUriForOldUser(), interactor.getBasicAuthHeaders())
|
||||
}
|
||||
|
|
@ -159,7 +159,13 @@ class Learn2earnViewModel @Inject constructor(
|
|||
.onSuccess { requestAwardResult ->
|
||||
requestAwardResult.fold(
|
||||
onSuccess = {
|
||||
analytics.send(Learn2earnEvents.MainScreen.NoticeClaimSuccess())
|
||||
analytics.send(
|
||||
Learn2earnEvents.MainScreen.NoticeClaimSuccess(
|
||||
clientType = getAnalyticsClientType(
|
||||
isUserHasPromo = interactor.isUserHadPromoCode(),
|
||||
),
|
||||
),
|
||||
)
|
||||
val onHideDialog = {
|
||||
uiState = uiState.updateViewsVisibility(isVisible = false)
|
||||
.hideDialog()
|
||||
|
|
@ -173,9 +179,13 @@ class Learn2earnViewModel @Inject constructor(
|
|||
},
|
||||
onFailure = {
|
||||
val errorDialog = createErrorDialog(it.toDomainError())
|
||||
updateUi {
|
||||
uiState.updateProgress(showProgress = false)
|
||||
.showDialog(errorDialog)
|
||||
if (errorDialog == null) {
|
||||
updateUi { uiState.updateProgress(showProgress = false) }
|
||||
} else {
|
||||
updateUi {
|
||||
uiState.updateProgress(showProgress = false)
|
||||
.showDialog(errorDialog)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
@ -184,8 +194,9 @@ class Learn2earnViewModel @Inject constructor(
|
|||
updateUi { uiState.updateProgress(showProgress = false) }
|
||||
if (exception is UserCancelledException) return@launch
|
||||
|
||||
val errorDialog = createErrorDialog(exception.toDomainError())
|
||||
updateUi { uiState.showDialog(errorDialog) }
|
||||
createErrorDialog(exception.toDomainError())?.let {
|
||||
updateUi { uiState.showDialog(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -198,7 +209,15 @@ class Learn2earnViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun createErrorDialog(error: PromotionError): MainScreenState.Dialog {
|
||||
private fun getAnalyticsClientType(isUserHasPromo: Boolean): AnalyticsParam.ClientType {
|
||||
return if (isUserHasPromo) {
|
||||
AnalyticsParam.ClientType.New()
|
||||
} else {
|
||||
AnalyticsParam.ClientType.Old()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createErrorDialog(error: PromotionError): MainScreenState.Dialog? {
|
||||
return when (error) {
|
||||
is PromotionError.CodeWasNotAppliedInShop -> {
|
||||
val onHideDialog = { uiState = uiState.hideDialog() }
|
||||
|
|
@ -237,6 +256,7 @@ class Learn2earnViewModel @Inject constructor(
|
|||
onDismissRequest = onHideDialog,
|
||||
)
|
||||
}
|
||||
is PromotionError.EmptyUserWalletId -> null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ interface UserWalletManager {
|
|||
fun getNativeTokenForNetwork(networkId: String): Currency
|
||||
|
||||
/**
|
||||
* Returns user walletId
|
||||
* Returns user walletId or empty string
|
||||
*/
|
||||
fun getWalletId(): String
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue