mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-21 13:40:31 -06:00
Compare commits
5 Commits
4.0.0-rc.1
...
fix/remove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fba692626 | ||
|
|
3ace91cdd5 | ||
|
|
4ba7bf5b3c | ||
|
|
bd1402a58b | ||
|
|
c2af0c3fb6 |
@@ -1,16 +1,13 @@
|
||||
package com.formbricks.demo
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.widget.Button
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import com.formbricks.formbrickssdk.Formbricks
|
||||
import com.formbricks.formbrickssdk.FormbricksCallback
|
||||
import com.formbricks.formbrickssdk.helper.FormbricksConfig
|
||||
import com.formbricks.formbrickssdk.model.enums.SuccessType
|
||||
import java.util.UUID
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
@@ -18,33 +15,6 @@ class MainActivity : AppCompatActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
Formbricks.callback = object: FormbricksCallback {
|
||||
override fun onSurveyStarted() {
|
||||
Log.d("FormbricksCallback", "onSurveyStarted")
|
||||
}
|
||||
|
||||
override fun onSurveyFinished() {
|
||||
Log.d("FormbricksCallback", "onSurveyFinished")
|
||||
}
|
||||
|
||||
override fun onSurveyClosed() {
|
||||
Log.d("FormbricksCallback", "onSurveyClosed")
|
||||
}
|
||||
|
||||
override fun onPageCommitVisible() {
|
||||
Log.d("FormbricksCallback", "onPageCommitVisible")
|
||||
}
|
||||
|
||||
override fun onError(error: Exception) {
|
||||
Log.d("FormbricksCallback", "onError from the CB: ${error.localizedMessage}")
|
||||
}
|
||||
|
||||
override fun onSuccess(successType: SuccessType) {
|
||||
Log.d("FormbricksCallback", "onSuccess: ${successType.name}")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
val config = FormbricksConfig.Builder("[appUrl]","[environmentId]")
|
||||
.setLoggingEnabled(true)
|
||||
.setFragmentManager(supportFragmentManager)
|
||||
|
||||
@@ -34,5 +34,4 @@
|
||||
-keep class com.formbricks.formbrickssdk.DataBinderMapperImpl { *; }
|
||||
-keep class com.formbricks.formbrickssdk.Formbricks { *; }
|
||||
-keep class com.formbricks.formbrickssdk.helper.FormbricksConfig { *; }
|
||||
-keep class com.formbricks.formbrickssdk.model.error.SDKError { *; }
|
||||
-keep interface com.formbricks.formbrickssdk.FormbricksCallback { *; }
|
||||
-keep class com.formbricks.formbrickssdk.model.error.SDKError { *; }
|
||||
@@ -10,22 +10,10 @@ import com.formbricks.formbrickssdk.helper.FormbricksConfig
|
||||
import com.formbricks.formbrickssdk.logger.Logger
|
||||
import com.formbricks.formbrickssdk.manager.SurveyManager
|
||||
import com.formbricks.formbrickssdk.manager.UserManager
|
||||
import com.formbricks.formbrickssdk.model.enums.SuccessType
|
||||
import com.formbricks.formbrickssdk.model.error.SDKError
|
||||
import com.formbricks.formbrickssdk.webview.FormbricksFragment
|
||||
import java.lang.RuntimeException
|
||||
|
||||
@Keep
|
||||
interface FormbricksCallback {
|
||||
fun onSurveyStarted()
|
||||
fun onSurveyFinished()
|
||||
fun onSurveyClosed()
|
||||
fun onPageCommitVisible()
|
||||
fun onError(error: Exception)
|
||||
fun onSuccess(successType: SuccessType)
|
||||
}
|
||||
|
||||
|
||||
@Keep
|
||||
object Formbricks {
|
||||
internal lateinit var applicationContext: Context
|
||||
@@ -37,8 +25,6 @@ object Formbricks {
|
||||
private var fragmentManager: FragmentManager? = null
|
||||
internal var isInitialized = false
|
||||
|
||||
var callback: FormbricksCallback? = null
|
||||
|
||||
/**
|
||||
* Initializes the Formbricks SDK with the given [Context] config [FormbricksConfig].
|
||||
* This method is mandatory to be called, and should be only once per application lifecycle.
|
||||
@@ -62,7 +48,6 @@ object Formbricks {
|
||||
fun setup(context: Context, config: FormbricksConfig, forceRefresh: Boolean = false) {
|
||||
if (isInitialized && !forceRefresh) {
|
||||
val error = SDKError.sdkIsAlreadyInitialized
|
||||
callback?.onError(error)
|
||||
Logger.e(error)
|
||||
return
|
||||
}
|
||||
@@ -97,14 +82,12 @@ object Formbricks {
|
||||
fun setUserId(userId: String) {
|
||||
if (!isInitialized) {
|
||||
val error = SDKError.sdkIsNotInitialized
|
||||
callback?.onError(error)
|
||||
Logger.e(error)
|
||||
return
|
||||
}
|
||||
|
||||
if(UserManager.userId != null) {
|
||||
val error = RuntimeException("A userId is already set ${UserManager.userId} - please call logout first before setting a new one")
|
||||
callback?.onError(error)
|
||||
Logger.e(error)
|
||||
return
|
||||
}
|
||||
@@ -124,7 +107,6 @@ object Formbricks {
|
||||
fun setAttribute(attribute: String, key: String) {
|
||||
if (!isInitialized) {
|
||||
val error = SDKError.sdkIsNotInitialized
|
||||
callback?.onError(error)
|
||||
Logger.e(error)
|
||||
return
|
||||
}
|
||||
@@ -143,7 +125,6 @@ object Formbricks {
|
||||
fun setAttributes(attributes: Map<String, String>) {
|
||||
if (!isInitialized) {
|
||||
val error = SDKError.sdkIsNotInitialized
|
||||
callback?.onError(error)
|
||||
Logger.e(error)
|
||||
return
|
||||
}
|
||||
@@ -162,7 +143,6 @@ object Formbricks {
|
||||
fun setLanguage(language: String) {
|
||||
if (!isInitialized) {
|
||||
val error = SDKError.sdkIsNotInitialized
|
||||
callback?.onError(error)
|
||||
Logger.e(error)
|
||||
return
|
||||
}
|
||||
@@ -182,14 +162,12 @@ object Formbricks {
|
||||
fun track(action: String) {
|
||||
if (!isInitialized) {
|
||||
val error = SDKError.sdkIsNotInitialized
|
||||
callback?.onError(error)
|
||||
Logger.e(error)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isInternetAvailable()) {
|
||||
val error = SDKError.connectionIsNotAvailable
|
||||
callback?.onError(error)
|
||||
Logger.e(error)
|
||||
return
|
||||
}
|
||||
@@ -209,12 +187,10 @@ object Formbricks {
|
||||
fun logout() {
|
||||
if (!isInitialized) {
|
||||
val error = SDKError.sdkIsNotInitialized
|
||||
callback?.onError(error)
|
||||
Logger.e(error)
|
||||
return
|
||||
}
|
||||
|
||||
callback?.onSuccess(SuccessType.LOGOUT_SUCCESS)
|
||||
UserManager.logout()
|
||||
}
|
||||
|
||||
@@ -236,7 +212,6 @@ object Formbricks {
|
||||
internal fun showSurvey(id: String) {
|
||||
if (fragmentManager == null) {
|
||||
val error = SDKError.fragmentManagerIsNotSet
|
||||
callback?.onError(error)
|
||||
Logger.e(error)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import com.formbricks.formbrickssdk.logger.Logger
|
||||
import com.formbricks.formbrickssdk.model.environment.EnvironmentDataHolder
|
||||
import com.formbricks.formbrickssdk.model.environment.Survey
|
||||
import com.formbricks.formbrickssdk.model.error.SDKError
|
||||
import com.formbricks.formbrickssdk.model.enums.SuccessType
|
||||
import com.formbricks.formbrickssdk.model.user.Display
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -60,7 +59,6 @@ object SurveyManager {
|
||||
try {
|
||||
Gson().fromJson(json, EnvironmentDataHolder::class.java)
|
||||
} catch (e: Exception) {
|
||||
Formbricks.callback?.onError(e)
|
||||
Logger.e(RuntimeException("Unable to retrieve environment data from the local storage."))
|
||||
null
|
||||
}
|
||||
@@ -118,11 +116,9 @@ object SurveyManager {
|
||||
startRefreshTimer(environmentDataHolder?.expiresAt())
|
||||
filterSurveys()
|
||||
hasApiError = false
|
||||
Formbricks.callback?.onSuccess(SuccessType.GET_ENVIRONMENT_SUCCESS)
|
||||
} catch (e: Exception) {
|
||||
hasApiError = true
|
||||
val error = SDKError.unableToRefreshEnvironment
|
||||
Formbricks.callback?.onError(error)
|
||||
Logger.e(error)
|
||||
startErrorTimer()
|
||||
}
|
||||
@@ -134,6 +130,7 @@ object SurveyManager {
|
||||
* Handles the display percentage and the delay of the survey.
|
||||
*/
|
||||
fun track(action: String) {
|
||||
print(environmentDataHolder?.data?.data?.actionClasses)
|
||||
val actionClasses = environmentDataHolder?.data?.data?.actionClasses ?: listOf()
|
||||
val codeActionClasses = actionClasses.filter { it.type == "code" }
|
||||
val actionClass = codeActionClasses.firstOrNull { it.key == action }
|
||||
@@ -143,7 +140,8 @@ object SurveyManager {
|
||||
}
|
||||
|
||||
if (firstSurveyWithActionClass == null) {
|
||||
Formbricks.callback?.onError(SDKError.surveyNotFoundError)
|
||||
val error = SDKError.surveyNotFoundError
|
||||
Logger.e(error)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -154,7 +152,6 @@ object SurveyManager {
|
||||
|
||||
if (languageCode == null) {
|
||||
val error = RuntimeException("Survey “${firstSurveyWithActionClass.name}” is not available in language “$currentLanguage”. Skipping.")
|
||||
Formbricks.callback?.onError(error)
|
||||
Logger.e(error)
|
||||
return
|
||||
}
|
||||
@@ -177,7 +174,8 @@ object SurveyManager {
|
||||
}, Date(System.currentTimeMillis() + timeout.toLong() * 1000))
|
||||
}
|
||||
} else {
|
||||
Formbricks.callback?.onError(SDKError.surveyNotDisplayedError)
|
||||
val error = SDKError.surveyNotDisplayedError
|
||||
Logger.e(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +190,6 @@ object SurveyManager {
|
||||
fun postResponse(surveyId: String?) {
|
||||
val id = surveyId.guard {
|
||||
val error = SDKError.missingSurveyId
|
||||
Formbricks.callback?.onError(error)
|
||||
Logger.e(error)
|
||||
return
|
||||
}
|
||||
@@ -206,7 +203,6 @@ object SurveyManager {
|
||||
fun onNewDisplay(surveyId: String?) {
|
||||
val id = surveyId.guard {
|
||||
val error = SDKError.missingSurveyId
|
||||
Formbricks.callback?.onError(error)
|
||||
Logger.e(error)
|
||||
return
|
||||
}
|
||||
@@ -269,7 +265,6 @@ object SurveyManager {
|
||||
|
||||
else -> {
|
||||
val error = SDKError.invalidDisplayOption
|
||||
Formbricks.callback?.onError(error)
|
||||
Logger.e(error)
|
||||
false
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import com.formbricks.formbrickssdk.extensions.guard
|
||||
import com.formbricks.formbrickssdk.extensions.lastDisplayAt
|
||||
import com.formbricks.formbrickssdk.logger.Logger
|
||||
import com.formbricks.formbrickssdk.model.error.SDKError
|
||||
import com.formbricks.formbrickssdk.model.enums.SuccessType
|
||||
import com.formbricks.formbrickssdk.model.user.Display
|
||||
import com.formbricks.formbrickssdk.network.queue.UpdateQueue
|
||||
import com.google.gson.Gson
|
||||
@@ -147,10 +146,8 @@ object UserManager {
|
||||
UpdateQueue.current.reset()
|
||||
SurveyManager.filterSurveys()
|
||||
startSyncTimer()
|
||||
Formbricks.callback?.onSuccess(SuccessType.SET_USER_SUCCESS)
|
||||
} catch (e: Exception) {
|
||||
val error = SDKError.unableToPostResponse
|
||||
Formbricks.callback?.onError(error)
|
||||
Logger.e(error)
|
||||
}
|
||||
}
|
||||
@@ -164,7 +161,6 @@ object UserManager {
|
||||
|
||||
if (!isUserIdDefined) {
|
||||
val error = SDKError.noUserIdSetError
|
||||
Formbricks.callback?.onError(error)
|
||||
Logger.e(error)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,5 +7,6 @@ import kotlinx.serialization.Serializable
|
||||
data class EnvironmentData(
|
||||
@SerializedName("surveys") val surveys: List<Survey>?,
|
||||
@SerializedName("actionClasses") val actionClasses: List<ActionClass>?,
|
||||
@SerializedName("project") val project: Project
|
||||
@SerializedName("project") val project: Project,
|
||||
@SerializedName("recaptchaSiteKey") val recaptchaSiteKey: String?
|
||||
)
|
||||
@@ -1,17 +1,183 @@
|
||||
package com.formbricks.formbrickssdk.model.environment
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.*
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.*
|
||||
import kotlinx.serialization.descriptors.*
|
||||
import kotlinx.serialization.encoding.*
|
||||
|
||||
// MARK: - Connector
|
||||
@Serializable
|
||||
enum class SegmentConnector {
|
||||
@SerialName("and") AND,
|
||||
@SerialName("or") OR
|
||||
}
|
||||
|
||||
// MARK: - Filter Operators
|
||||
@Serializable
|
||||
enum class FilterOperator {
|
||||
@SerialName("lessThan") LESS_THAN,
|
||||
@SerialName("lessEqual") LESS_EQUAL,
|
||||
@SerialName("greaterThan") GREATER_THAN,
|
||||
@SerialName("greaterEqual") GREATER_EQUAL,
|
||||
@SerialName("equals") EQUALS,
|
||||
@SerialName("notEquals") NOT_EQUALS,
|
||||
@SerialName("contains") CONTAINS,
|
||||
@SerialName("doesNotContain") DOES_NOT_CONTAIN,
|
||||
@SerialName("startsWith") STARTS_WITH,
|
||||
@SerialName("endsWith") ENDS_WITH,
|
||||
@SerialName("isSet") IS_SET,
|
||||
@SerialName("isNotSet") IS_NOT_SET,
|
||||
@SerialName("userIsIn") USER_IS_IN,
|
||||
@SerialName("userIsNotIn") USER_IS_NOT_IN
|
||||
}
|
||||
|
||||
// MARK: - Filter Value
|
||||
@Serializable(with = SegmentFilterValueSerializer::class)
|
||||
sealed class SegmentFilterValue {
|
||||
data class StringValue(val value: String) : SegmentFilterValue()
|
||||
data class NumberValue(val value: Double) : SegmentFilterValue()
|
||||
}
|
||||
|
||||
object SegmentFilterValueSerializer : KSerializer<SegmentFilterValue> {
|
||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("SegmentFilterValue", PrimitiveKind.STRING)
|
||||
override fun deserialize(decoder: Decoder): SegmentFilterValue {
|
||||
val jsonInput = decoder as JsonDecoder
|
||||
val element = jsonInput.decodeJsonElement()
|
||||
return when (element) {
|
||||
is JsonPrimitive -> {
|
||||
element.doubleOrNull?.let { SegmentFilterValue.NumberValue(it) }
|
||||
?: SegmentFilterValue.StringValue(element.content)
|
||||
}
|
||||
else -> throw SerializationException("Unexpected type for SegmentFilterValue: $element")
|
||||
}
|
||||
}
|
||||
override fun serialize(encoder: Encoder, value: SegmentFilterValue) {
|
||||
val jsonOutput = encoder as JsonEncoder
|
||||
val element = when (value) {
|
||||
is SegmentFilterValue.NumberValue -> JsonPrimitive(value.value)
|
||||
is SegmentFilterValue.StringValue -> JsonPrimitive(value.value)
|
||||
}
|
||||
jsonOutput.encodeJsonElement(element)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Filter Root
|
||||
@Serializable(with = SegmentFilterRootSerializer::class)
|
||||
sealed class SegmentFilterRoot {
|
||||
data class Attribute(val contactAttributeKey: String) : SegmentFilterRoot()
|
||||
data class Person(val personIdentifier: String) : SegmentFilterRoot()
|
||||
data class Segment(val segmentId: String) : SegmentFilterRoot()
|
||||
data class Device(val deviceType: String) : SegmentFilterRoot()
|
||||
}
|
||||
|
||||
object SegmentFilterRootSerializer : KSerializer<SegmentFilterRoot> {
|
||||
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("SegmentFilterRoot") {
|
||||
element<String>("type")
|
||||
}
|
||||
override fun deserialize(decoder: Decoder): SegmentFilterRoot {
|
||||
val input = decoder as JsonDecoder
|
||||
val obj = input.decodeJsonElement().jsonObject
|
||||
return when (val type = obj["type"]?.jsonPrimitive?.content) {
|
||||
"attribute" -> SegmentFilterRoot.Attribute(obj["contactAttributeKey"]!!.jsonPrimitive.content)
|
||||
"person" -> SegmentFilterRoot.Person(obj["personIdentifier"]!!.jsonPrimitive.content)
|
||||
"segment" -> SegmentFilterRoot.Segment(obj["segmentId"]!!.jsonPrimitive.content)
|
||||
"device" -> SegmentFilterRoot.Device(obj["deviceType"]!!.jsonPrimitive.content)
|
||||
else -> throw SerializationException("Unknown root type: $type")
|
||||
}
|
||||
}
|
||||
override fun serialize(encoder: Encoder, value: SegmentFilterRoot) {
|
||||
val output = encoder as JsonEncoder
|
||||
val json = buildJsonObject {
|
||||
when (value) {
|
||||
is SegmentFilterRoot.Attribute -> {
|
||||
put("type", JsonPrimitive("attribute"))
|
||||
put("contactAttributeKey", JsonPrimitive(value.contactAttributeKey))
|
||||
}
|
||||
is SegmentFilterRoot.Person -> {
|
||||
put("type", JsonPrimitive("person"))
|
||||
put("personIdentifier", JsonPrimitive(value.personIdentifier))
|
||||
}
|
||||
is SegmentFilterRoot.Segment -> {
|
||||
put("type", JsonPrimitive("segment"))
|
||||
put("segmentId", JsonPrimitive(value.segmentId))
|
||||
}
|
||||
is SegmentFilterRoot.Device -> {
|
||||
put("type", JsonPrimitive("device"))
|
||||
put("deviceType", JsonPrimitive(value.deviceType))
|
||||
}
|
||||
}
|
||||
}
|
||||
output.encodeJsonElement(json)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Qualifier
|
||||
@Serializable
|
||||
data class SegmentFilterQualifier(
|
||||
@SerialName("operator") val `operator`: FilterOperator
|
||||
)
|
||||
|
||||
// MARK: - Primitive Filter
|
||||
@Serializable
|
||||
data class SegmentPrimitiveFilter(
|
||||
val id: String,
|
||||
val root: SegmentFilterRoot,
|
||||
val value: SegmentFilterValue,
|
||||
val qualifier: SegmentFilterQualifier
|
||||
)
|
||||
|
||||
// MARK: - Recursive Resource
|
||||
@Serializable(with = SegmentFilterResourceSerializer::class)
|
||||
sealed class SegmentFilterResource {
|
||||
data class Primitive(val filter: SegmentPrimitiveFilter) : SegmentFilterResource()
|
||||
data class Group(val filters: List<SegmentFilter>) : SegmentFilterResource()
|
||||
}
|
||||
|
||||
object SegmentFilterResourceSerializer : KSerializer<SegmentFilterResource> {
|
||||
override val descriptor = buildClassSerialDescriptor("SegmentFilterResource") {
|
||||
// You can declare children here if you like,
|
||||
// or leave it empty if you’re purely passing through.
|
||||
}
|
||||
override fun deserialize(decoder: Decoder): SegmentFilterResource {
|
||||
val input = decoder as JsonDecoder
|
||||
val element = input.decodeJsonElement()
|
||||
return if (element is JsonArray) {
|
||||
val list = element.map { input.json.decodeFromJsonElement(SegmentFilter.serializer(), it) }
|
||||
SegmentFilterResource.Group(list)
|
||||
} else {
|
||||
val prim = input.json.decodeFromJsonElement(SegmentPrimitiveFilter.serializer(), element)
|
||||
SegmentFilterResource.Primitive(prim)
|
||||
}
|
||||
}
|
||||
override fun serialize(encoder: Encoder, value: SegmentFilterResource) {
|
||||
val output = encoder as JsonEncoder
|
||||
val json = when (value) {
|
||||
is SegmentFilterResource.Primitive -> output.json.encodeToJsonElement(SegmentPrimitiveFilter.serializer(), value.filter)
|
||||
is SegmentFilterResource.Group -> output.json.encodeToJsonElement(ListSerializer(SegmentFilter.serializer()), value.filters)
|
||||
}
|
||||
output.encodeJsonElement(json)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Filter Node
|
||||
@Serializable
|
||||
data class SegmentFilter(
|
||||
val id: String,
|
||||
val connector: SegmentConnector? = null,
|
||||
val resource: SegmentFilterResource
|
||||
)
|
||||
|
||||
// MARK: - Segment Model
|
||||
@Serializable
|
||||
data class Segment(
|
||||
@SerializedName("id") val id: String? = null,
|
||||
@SerializedName("createdAt") val createdAt: String? = null,
|
||||
@SerializedName("updatedAt") val updatedAt: String? = null,
|
||||
@SerializedName("title") val title: String? = null,
|
||||
@SerializedName("description") val description: String? = null,
|
||||
@SerializedName("isPrivate") val isPrivate: Boolean? = null,
|
||||
@SerializedName("filters") val filters: List<String>? = null,
|
||||
@SerializedName("environmentId") val environmentId: String? = null,
|
||||
@SerializedName("surveys") val surveys: List<String>? = null
|
||||
)
|
||||
val id: String,
|
||||
val title: String,
|
||||
val description: String? = null,
|
||||
@SerialName("isPrivate") val isPrivate: Boolean,
|
||||
val filters: List<SegmentFilter>,
|
||||
val environmentId: String,
|
||||
val createdAt: String,
|
||||
val updatedAt: String,
|
||||
val surveys: List<String>
|
||||
)
|
||||
|
||||
@@ -30,5 +30,5 @@ data class Survey(
|
||||
@SerializedName("displayOption") val displayOption: String?,
|
||||
@SerializedName("segment") val segment: Segment?,
|
||||
@SerializedName("styling") val styling: Styling?,
|
||||
@SerializedName("languages") val languages: List<SurveyLanguage>?
|
||||
@SerializedName("languages") val languages: List<SurveyLanguage>?,
|
||||
)
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.formbricks.formbrickssdk.network.queue
|
||||
|
||||
import com.formbricks.formbrickssdk.Formbricks
|
||||
import com.formbricks.formbrickssdk.logger.Logger
|
||||
import com.formbricks.formbrickssdk.manager.UserManager
|
||||
import com.formbricks.formbrickssdk.model.error.SDKError
|
||||
@@ -68,7 +67,6 @@ class UpdateQueue private constructor() {
|
||||
?: UserManager.userId
|
||||
if (effectiveUserId == null) {
|
||||
val error = SDKError.noUserIdSetError
|
||||
Formbricks.callback?.onError(error)
|
||||
Logger.e(error)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ import android.widget.FrameLayout
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import com.formbricks.formbrickssdk.Formbricks
|
||||
import com.formbricks.formbrickssdk.R
|
||||
import com.formbricks.formbrickssdk.databinding.FragmentFormbricksBinding
|
||||
import com.formbricks.formbrickssdk.logger.Logger
|
||||
@@ -37,26 +36,22 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import com.google.gson.JsonObject
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
import java.util.Timer
|
||||
|
||||
|
||||
class FormbricksFragment : BottomSheetDialogFragment() {
|
||||
|
||||
private lateinit var binding: FragmentFormbricksBinding
|
||||
private lateinit var surveyId: String
|
||||
private val closeTimer = Timer()
|
||||
private val viewModel: FormbricksViewModel by viewModels()
|
||||
|
||||
private var webAppInterface = WebAppInterface(object : WebAppInterface.WebAppCallback {
|
||||
override fun onClose() {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
Formbricks.callback?.onSurveyClosed()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDisplayCreated() {
|
||||
Formbricks.callback?.onSurveyStarted()
|
||||
SurveyManager.onNewDisplay(surveyId)
|
||||
}
|
||||
|
||||
@@ -74,7 +69,8 @@ class FormbricksFragment : BottomSheetDialogFragment() {
|
||||
}
|
||||
|
||||
override fun onSurveyLibraryLoadError() {
|
||||
Formbricks.callback?.onError(SDKError.unableToLoadFormbicksJs)
|
||||
val error = SDKError.unableToLoadFormbicksJs
|
||||
Logger.e(error)
|
||||
dismiss()
|
||||
}
|
||||
})
|
||||
@@ -155,7 +151,8 @@ class FormbricksFragment : BottomSheetDialogFragment() {
|
||||
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
|
||||
consoleMessage?.let { cm ->
|
||||
if (cm.messageLevel() == ConsoleMessage.MessageLevel.ERROR) {
|
||||
Formbricks.callback?.onError(SDKError.surveyDisplayFetchError)
|
||||
val error = SDKError.surveyDisplayFetchError
|
||||
Logger.e(error)
|
||||
dismiss()
|
||||
}
|
||||
val log = "[CONSOLE:${cm.messageLevel()}] \"${cm.message()}\", source: ${cm.sourceId()} (${cm.lineNumber()})"
|
||||
|
||||
@@ -99,7 +99,7 @@ class FormbricksViewModel : ViewModel() {
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = "${Formbricks.appUrl}/js/surveys.umd.cjs";
|
||||
script.src = "${Formbricks.appUrl}/js/surveys.umd.cjs?randQuery=123445";
|
||||
script.async = true;
|
||||
script.onload = () => loadSurvey();
|
||||
script.onerror = (error) => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.formbricks.formbrickssdk.webview
|
||||
|
||||
import android.webkit.JavascriptInterface
|
||||
import com.formbricks.formbrickssdk.Formbricks
|
||||
import com.formbricks.formbrickssdk.logger.Logger
|
||||
import com.formbricks.formbrickssdk.model.javascript.JsMessageData
|
||||
import com.formbricks.formbrickssdk.model.javascript.EventType
|
||||
@@ -36,15 +35,12 @@ class WebAppInterface(private val callback: WebAppCallback?) {
|
||||
EventType.ON_SURVEY_LIBRARY_LOAD_ERROR -> { callback?.onSurveyLibraryLoadError() }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Formbricks.callback?.onError(e)
|
||||
Logger.e(RuntimeException(e.message))
|
||||
} catch (e: JsonParseException) {
|
||||
Logger.e(RuntimeException("Failed to parse JSON message: $data"))
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Formbricks.callback?.onError(e)
|
||||
Logger.e(RuntimeException("Invalid message format: $data"))
|
||||
} catch (e: Exception) {
|
||||
Formbricks.callback?.onError(e)
|
||||
Logger.e(RuntimeException("Unexpected error processing message: $data"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,10 @@
|
||||
import UIKit
|
||||
import FormbricksSDK
|
||||
|
||||
class AppDelegate: NSObject, UIApplicationDelegate, FormbricksDelegate {
|
||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
|
||||
func application(_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||
Formbricks.delegate = self
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - FormbricksDelegate
|
||||
|
||||
func onSurveyStarted() {
|
||||
print("from the delegate: survey started")
|
||||
}
|
||||
|
||||
func onSurveyFinished() {
|
||||
print("survey finished")
|
||||
}
|
||||
|
||||
func onSurveyClosed() {
|
||||
print("survey closed")
|
||||
}
|
||||
|
||||
func onError(_ error: Error) {
|
||||
print("survey error:", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
/// Formbricks SDK delegate protocol. It contains the main methods to interact with the SDK.
|
||||
public protocol FormbricksDelegate: AnyObject {
|
||||
func onSurveyStarted()
|
||||
func onSurveyFinished()
|
||||
func onSurveyClosed()
|
||||
func onError(_ error: Error)
|
||||
}
|
||||
|
||||
/// The main class of the Formbricks SDK. It contains the main methods to interact with the SDK.
|
||||
@objc(Formbricks) public class Formbricks: NSObject {
|
||||
|
||||
@@ -23,7 +15,6 @@ public protocol FormbricksDelegate: AnyObject {
|
||||
static internal var apiQueue: OperationQueue? = OperationQueue()
|
||||
static internal var logger: Logger?
|
||||
static internal var service = FormbricksService()
|
||||
public static weak var delegate: FormbricksDelegate?
|
||||
|
||||
// make this class not instantiatable outside of the SDK
|
||||
internal override init() {
|
||||
@@ -58,7 +49,6 @@ public protocol FormbricksDelegate: AnyObject {
|
||||
|
||||
guard !isInitialized else {
|
||||
let error = FormbricksSDKError(type: .sdkIsAlreadyInitialized)
|
||||
delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return
|
||||
}
|
||||
@@ -101,7 +91,6 @@ public protocol FormbricksDelegate: AnyObject {
|
||||
@objc public static func setUserId(_ userId: String) {
|
||||
guard Formbricks.isInitialized else {
|
||||
let error = FormbricksSDKError(type: .sdkIsNotInitialized)
|
||||
delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return
|
||||
}
|
||||
@@ -126,7 +115,6 @@ public protocol FormbricksDelegate: AnyObject {
|
||||
@objc public static func setAttribute(_ attribute: String, forKey key: String) {
|
||||
guard Formbricks.isInitialized else {
|
||||
let error = FormbricksSDKError(type: .sdkIsNotInitialized)
|
||||
delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return
|
||||
}
|
||||
@@ -146,7 +134,6 @@ public protocol FormbricksDelegate: AnyObject {
|
||||
@objc public static func setAttributes(_ attributes: [String : String]) {
|
||||
guard Formbricks.isInitialized else {
|
||||
let error = FormbricksSDKError(type: .sdkIsNotInitialized)
|
||||
delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return
|
||||
}
|
||||
@@ -166,7 +153,6 @@ public protocol FormbricksDelegate: AnyObject {
|
||||
@objc public static func setLanguage(_ language: String) {
|
||||
guard Formbricks.isInitialized else {
|
||||
let error = FormbricksSDKError(type: .sdkIsNotInitialized)
|
||||
delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return
|
||||
}
|
||||
@@ -191,7 +177,6 @@ public protocol FormbricksDelegate: AnyObject {
|
||||
@objc public static func track(_ action: String) {
|
||||
guard Formbricks.isInitialized else {
|
||||
let error = FormbricksSDKError(type: .sdkIsNotInitialized)
|
||||
delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return
|
||||
}
|
||||
@@ -218,7 +203,6 @@ public protocol FormbricksDelegate: AnyObject {
|
||||
@objc public static func logout() {
|
||||
guard Formbricks.isInitialized else {
|
||||
let error = FormbricksSDKError(type: .sdkIsNotInitialized)
|
||||
delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -106,7 +106,6 @@ extension SurveyManager {
|
||||
case .failure:
|
||||
self?.hasApiError = true
|
||||
let error = FormbricksSDKError(type: .unableToRefreshEnvironment)
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
self?.startErrorTimer()
|
||||
}
|
||||
@@ -186,7 +185,6 @@ extension SurveyManager {
|
||||
return try? JSONDecoder().decode(EnvironmentResponse.self, from: data)
|
||||
} else {
|
||||
let error = FormbricksSDKError(type: .unableToRetrieveEnvironment)
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return nil
|
||||
}
|
||||
@@ -197,7 +195,6 @@ extension SurveyManager {
|
||||
backingEnvironmentResponse = newValue
|
||||
} else {
|
||||
let error = FormbricksSDKError(type: .unableToPersistEnvironment)
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
}
|
||||
}
|
||||
@@ -231,7 +228,6 @@ private extension SurveyManager {
|
||||
|
||||
default:
|
||||
let error = FormbricksSDKError(type: .invalidDisplayOption)
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -102,7 +102,6 @@ final class UserManager: UserManagerSyncable {
|
||||
self?.surveyManager?.filterSurveys()
|
||||
self?.startSyncTimer()
|
||||
case .failure(let error):
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,5 @@ struct EnvironmentData: Codable {
|
||||
let surveys: [Survey]?
|
||||
let actionClasses: [ActionClass]?
|
||||
let project: Project
|
||||
let recaptchaSiteKey: String?
|
||||
}
|
||||
|
||||
@@ -1,11 +1,198 @@
|
||||
struct Segment: Codable {
|
||||
let id: String?
|
||||
let createdAt: String?
|
||||
let updatedAt: String?
|
||||
let title: String?
|
||||
let description: String?
|
||||
let isPrivate: Bool?
|
||||
let filters: [String]?
|
||||
let environmentId: String?
|
||||
let surveys: [String]?
|
||||
import Foundation
|
||||
|
||||
// MARK: - Connector
|
||||
|
||||
enum SegmentConnector: String, Codable {
|
||||
case and
|
||||
case or
|
||||
}
|
||||
|
||||
// MARK: - Filter Operators
|
||||
|
||||
/// Combined operator set for all filter types
|
||||
enum FilterOperator: String, Codable {
|
||||
// Base / Arithmetic
|
||||
case lessThan
|
||||
case lessEqual
|
||||
case greaterThan
|
||||
case greaterEqual
|
||||
case equals
|
||||
case notEquals
|
||||
// Attribute / String
|
||||
case contains
|
||||
case doesNotContain
|
||||
case startsWith
|
||||
case endsWith
|
||||
// Existence
|
||||
case isSet
|
||||
case isNotSet
|
||||
// Segment membership
|
||||
case userIsIn
|
||||
case userIsNotIn
|
||||
}
|
||||
|
||||
// MARK: - Filter Value
|
||||
|
||||
enum SegmentFilterValue: Codable {
|
||||
case string(String)
|
||||
case number(Double)
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if let num = try? container.decode(Double.self) {
|
||||
self = .number(num)
|
||||
} else if let str = try? container.decode(String.self) {
|
||||
self = .string(str)
|
||||
} else {
|
||||
throw DecodingError.typeMismatch(
|
||||
SegmentFilterValue.self,
|
||||
DecodingError.Context(
|
||||
codingPath: decoder.codingPath,
|
||||
debugDescription: "Value is neither Double nor String"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch self {
|
||||
case .number(let num):
|
||||
try container.encode(num)
|
||||
case .string(let str):
|
||||
try container.encode(str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Root
|
||||
|
||||
enum SegmentFilterRoot: Codable {
|
||||
case attribute(contactAttributeKey: String)
|
||||
case person(personIdentifier: String)
|
||||
case segment(segmentId: String)
|
||||
case device(deviceType: String)
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
case contactAttributeKey
|
||||
case personIdentifier
|
||||
case segmentId
|
||||
case deviceType
|
||||
}
|
||||
|
||||
private enum RootType: String, Codable {
|
||||
case attribute
|
||||
case person
|
||||
case segment
|
||||
case device
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let type = try container.decode(RootType.self, forKey: .type)
|
||||
switch type {
|
||||
case .attribute:
|
||||
let key = try container.decode(String.self, forKey: .contactAttributeKey)
|
||||
self = .attribute(contactAttributeKey: key)
|
||||
case .person:
|
||||
let id = try container.decode(String.self, forKey: .personIdentifier)
|
||||
self = .person(personIdentifier: id)
|
||||
case .segment:
|
||||
let id = try container.decode(String.self, forKey: .segmentId)
|
||||
self = .segment(segmentId: id)
|
||||
case .device:
|
||||
let type = try container.decode(String.self, forKey: .deviceType)
|
||||
self = .device(deviceType: type)
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
switch self {
|
||||
case .attribute(let key):
|
||||
try container.encode(RootType.attribute, forKey: .type)
|
||||
try container.encode(key, forKey: .contactAttributeKey)
|
||||
case .person(let id):
|
||||
try container.encode(RootType.person, forKey: .type)
|
||||
try container.encode(id, forKey: .personIdentifier)
|
||||
case .segment(let id):
|
||||
try container.encode(RootType.segment, forKey: .type)
|
||||
try container.encode(id, forKey: .segmentId)
|
||||
case .device(let type):
|
||||
try container.encode(RootType.device, forKey: .type)
|
||||
try container.encode(type, forKey: .deviceType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Qualifier
|
||||
|
||||
struct SegmentFilterQualifier: Codable {
|
||||
let `operator`: FilterOperator
|
||||
}
|
||||
|
||||
// MARK: - Primitive Filter
|
||||
|
||||
struct SegmentPrimitiveFilter: Codable {
|
||||
let id: String
|
||||
let root: SegmentFilterRoot
|
||||
let value: SegmentFilterValue
|
||||
let qualifier: SegmentFilterQualifier
|
||||
|
||||
// Add run-time refinements if needed
|
||||
}
|
||||
|
||||
// MARK: - Recursive Filter Resource
|
||||
|
||||
enum SegmentFilterResource: Codable {
|
||||
case primitive(SegmentPrimitiveFilter)
|
||||
case group([SegmentFilter])
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
// Try primitive first
|
||||
if let prim = try? SegmentPrimitiveFilter(from: decoder) {
|
||||
self = .primitive(prim)
|
||||
} else {
|
||||
let nested = try [SegmentFilter](from: decoder)
|
||||
self = .group(nested)
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
switch self {
|
||||
case .primitive(let prim):
|
||||
try prim.encode(to: encoder)
|
||||
case .group(let arr):
|
||||
try arr.encode(to: encoder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Base Filter (node)
|
||||
|
||||
struct SegmentFilter: Codable {
|
||||
let id: String
|
||||
let connector: SegmentConnector?
|
||||
let resource: SegmentFilterResource
|
||||
}
|
||||
|
||||
// MARK: - Segment Model
|
||||
|
||||
struct Segment: Codable {
|
||||
let id: String
|
||||
let title: String
|
||||
let description: String?
|
||||
let isPrivate: Bool
|
||||
let filters: [SegmentFilter]
|
||||
let environmentId: String
|
||||
let createdAt: Date
|
||||
let updatedAt: Date
|
||||
let surveys: [String]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, title, description, filters, surveys
|
||||
case isPrivate = "isPrivate"
|
||||
case environmentId, createdAt, updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ class APIClient<Request: CodableRequest>: Operation, @unchecked Sendable {
|
||||
private func processResponse(data: Data?, response: URLResponse?, error: Error?) {
|
||||
guard let httpStatus = (response as? HTTPURLResponse)?.status else {
|
||||
let error = FormbricksAPIClientError(type: .invalidResponse)
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error("ERROR \(error.message)")
|
||||
completion?(.failure(error))
|
||||
return
|
||||
@@ -57,7 +56,6 @@ class APIClient<Request: CodableRequest>: Operation, @unchecked Sendable {
|
||||
private func handleSuccessResponse(data: Data?, statusCode: Int, message: inout String) {
|
||||
guard let data = data else {
|
||||
let error = FormbricksAPIClientError(type: .invalidResponse, statusCode: statusCode)
|
||||
Formbricks.delegate?.onError(error)
|
||||
completion?(.failure(error))
|
||||
return
|
||||
}
|
||||
@@ -89,16 +87,13 @@ class APIClient<Request: CodableRequest>: Operation, @unchecked Sendable {
|
||||
|
||||
if let error = error {
|
||||
log.append("\nError: \(error.localizedDescription)")
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error(log)
|
||||
completion?(.failure(error))
|
||||
} else if let data = data, let apiError = try? request.decoder.decode(FormbricksAPIError.self, from: data) {
|
||||
Formbricks.delegate?.onError(apiError)
|
||||
Formbricks.logger?.error("\(log)\n\(apiError.getDetailedErrorMessage())")
|
||||
completion?(.failure(apiError))
|
||||
} else {
|
||||
let error = FormbricksAPIClientError(type: .responseError, statusCode: statusCode)
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error("\(log)\n\(error.message)")
|
||||
completion?(.failure(error))
|
||||
}
|
||||
@@ -119,10 +114,8 @@ class APIClient<Request: CodableRequest>: Operation, @unchecked Sendable {
|
||||
}
|
||||
|
||||
let error = FormbricksAPIClientError(type: .invalidResponse, statusCode: statusCode)
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
completion?(.failure(error))
|
||||
|
||||
}
|
||||
|
||||
private func logRequest(_ request: URLRequest) {
|
||||
|
||||
@@ -101,7 +101,6 @@ private extension UpdateQueue {
|
||||
|
||||
guard let userId = effectiveUserId else {
|
||||
let error = FormbricksSDKError(type: .userIdIsNotSetYet)
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -110,7 +110,6 @@ private class WebViewData {
|
||||
let jsonData = try JSONSerialization.data(withJSONObject: data, options: [])
|
||||
return String(data: jsonData, encoding: .utf8)?.replacingOccurrences(of: "\\\"", with: "'")
|
||||
} catch {
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -119,12 +119,10 @@ final class JsMessageHandler: NSObject, WKScriptMessageHandler {
|
||||
|
||||
/// Happens when a survey is shown.
|
||||
case .onDisplayCreated:
|
||||
Formbricks.delegate?.onSurveyStarted()
|
||||
Formbricks.surveyManager?.onNewDisplay(surveyId: surveyId)
|
||||
|
||||
/// Happens when the user closes the survey view with the close button.
|
||||
case .onClose:
|
||||
Formbricks.delegate?.onSurveyClosed()
|
||||
Formbricks.surveyManager?.dismissSurveyWebView()
|
||||
|
||||
/// Happens when the survey wants to open an external link in the default browser.
|
||||
@@ -140,7 +138,6 @@ final class JsMessageHandler: NSObject, WKScriptMessageHandler {
|
||||
|
||||
} else {
|
||||
let error = FormbricksSDKError(type: .invalidJavascriptMessage)
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error("\(error.message): \(message.body)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,7 +210,6 @@ const refineFilters = (filters: TBaseFilters): boolean => {
|
||||
// The filters can be nested, so we need to use z.lazy to define the type
|
||||
// more on recusrsive types -> https://zod.dev/?id=recursive-types
|
||||
|
||||
// TODO: Figure out why this is not working, and then remove the ts-ignore
|
||||
export const ZSegmentFilters: z.ZodType<TBaseFilters> = z
|
||||
.array(
|
||||
z.object({
|
||||
|
||||
Reference in New Issue
Block a user