Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1977,6 +1977,19 @@
"storage-total": "Total",
"storage-usage": "Storage usage: ",
"stored-externally": "stored externally",
"store-release-onboarding-badge": "One onboarding step left",
"store-release-onboarding-body": "Capgo has seen a Live Update bundle for this app, but not a store-installed app with the updater plugin yet. This step disappears after a store build opens and checks in.",
"store-release-onboarding-body-testflight": "A TestFlight build has checked in. Publish the same native build to the App Store so production users get the updater plugin too.",
"store-release-onboarding-body-track-unknown": "An Android store build has checked in, but Android does not expose whether it came from production, alpha, beta, or internal testing. This does not complete the store-release step.",
"store-release-onboarding-builds": "Open Builds",
"store-release-onboarding-devices": "View Devices",
"store-release-onboarding-step-live-update": "Live Update tested",
"store-release-onboarding-step-store-release": "App Store build opened",
"store-release-onboarding-step-testflight": "TestFlight detected",
"store-release-onboarding-step-track-unknown": "Android store detected, release track unknown",
"store-release-onboarding-title": "Release an App Store build with Capgo installed.",
"store-release-onboarding-title-testflight": "TestFlight is detected. Publish the store build to finish onboarding.",
"store-release-onboarding-title-track-unknown": "Android store install detected, but the release track is unknown.",
"test-preview": "Test preview",
"stripe-billing-portal-will-be-opened-in-a-new-tab": "Stripe billing portal will be opened in a new tab",
"subscribed-events": "Subscribed Events",
Expand Down
1 change: 1 addition & 0 deletions src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ declare module 'vue' {
StepsApp: typeof import('./components/dashboard/StepsApp.vue')['default']
StepsBuild: typeof import('./components/dashboard/StepsBuild.vue')['default']
StepsBundle: typeof import('./components/dashboard/StepsBundle.vue')['default']
StoreReleaseOnboardingBanner: typeof import('./components/dashboard/StoreReleaseOnboardingBanner.vue')['default']
TableLog: typeof import('./components/TableLog.vue')['default']
Tabs: typeof import('./components/Tabs.vue')['default']
TabSidebar: typeof import('./components/TabSidebar.vue')['default']
Expand Down
218 changes: 218 additions & 0 deletions src/components/dashboard/StoreReleaseOnboardingBanner.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import IconCheck from '~icons/lucide/check'
import IconCircleDot from '~icons/lucide/circle-dot'
import IconExternalLink from '~icons/lucide/external-link'
import IconSmartphone from '~icons/lucide/smartphone'
import IconStore from '~icons/lucide/store'
import { defaultApiHost, useSupabase } from '~/services/supabase'
import { useOrganizationStore } from '~/stores/organization'

const props = defineProps<{
appId: string
}>()

const RELEASE_INSTALL_SOURCES = ['app_store']
const TEST_INSTALL_SOURCES = ['testflight']
const TRACK_UNKNOWN_INSTALL_SOURCES = ['google_play', 'amazon_appstore', 'samsung_galaxy_store', 'huawei_appgallery']

const { t } = useI18n()
const router = useRouter()
const supabase = useSupabase()
const organizationStore = useOrganizationStore()

const isLoading = ref(true)
const hasLiveUpdateBundle = ref(false)
const hasStoreInstalledDevice = ref(false)
const hasTestFlightDevice = ref(false)
const hasTrackUnknownDevice = ref(false)
let loadStatusRequestId = 0

function resetStatus() {
hasLiveUpdateBundle.value = false
hasStoreInstalledDevice.value = false
hasTestFlightDevice.value = false
hasTrackUnknownDevice.value = false
}

async function countDevicesByInstallSource(appId: string, installSources: string[]) {
const { data: currentSession } = await supabase.auth.getSession()!
const currentJwt = currentSession.session?.access_token
if (!currentJwt)
return 0

const response = await fetch(`${defaultApiHost}/private/devices`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'authorization': `Bearer ${currentJwt}`,
},
body: JSON.stringify({
count: true,
appId,
installSources,
}),
})

if (!response.ok)
throw new Error(`Cannot count devices by install source: ${response.status}`)

const data = await response.json() as { count: number }
return data.count
}

const shouldShowBanner = computed(() => {
return !isLoading.value && hasLiveUpdateBundle.value && !hasStoreInstalledDevice.value
})

const title = computed(() => {
return hasTrackUnknownDevice.value
? t('store-release-onboarding-title-track-unknown')
: hasTestFlightDevice.value
? t('store-release-onboarding-title-testflight')
: t('store-release-onboarding-title')

Check warning on line 75 in src/components/dashboard/StoreReleaseOnboardingBanner.vue

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=Cap-go_capgo&issues=AZ6PiFlj2Ak3xlgmGl98&open=AZ6PiFlj2Ak3xlgmGl98&pullRequest=2413
})

const body = computed(() => {
return hasTrackUnknownDevice.value
? t('store-release-onboarding-body-track-unknown')
: hasTestFlightDevice.value
? t('store-release-onboarding-body-testflight')
: t('store-release-onboarding-body')

Check warning on line 83 in src/components/dashboard/StoreReleaseOnboardingBanner.vue

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=Cap-go_capgo&issues=AZ6PiFlj2Ak3xlgmGl97&open=AZ6PiFlj2Ak3xlgmGl97&pullRequest=2413
})

async function loadStatus() {
const requestId = ++loadStatusRequestId
const appId = props.appId

if (!appId) {
resetStatus()
isLoading.value = false
return
}

isLoading.value = true
try {
await organizationStore.awaitInitialLoad()

const [bundleResult, releaseDeviceCount, testDeviceCount, trackUnknownDeviceCount] = await Promise.all([
supabase
.from('app_versions')
.select('id')
.eq('app_id', appId)
.eq('deleted', false)
.neq('name', 'unknown')
.neq('name', 'builtin')
.limit(1),
countDevicesByInstallSource(appId, RELEASE_INSTALL_SOURCES),
countDevicesByInstallSource(appId, TEST_INSTALL_SOURCES),
countDevicesByInstallSource(appId, TRACK_UNKNOWN_INSTALL_SOURCES),
])

if (requestId !== loadStatusRequestId)
return

if (bundleResult.error) {
console.error('Cannot load store release onboarding status', {
bundleError: bundleResult.error,
})
resetStatus()
return
}

hasLiveUpdateBundle.value = (bundleResult.data?.length ?? 0) > 0
hasStoreInstalledDevice.value = releaseDeviceCount > 0
hasTestFlightDevice.value = testDeviceCount > 0
hasTrackUnknownDevice.value = trackUnknownDeviceCount > 0
Comment thread
riderx marked this conversation as resolved.
}
catch (error) {
if (requestId !== loadStatusRequestId)
return
console.error('Cannot load store release onboarding status', error)
resetStatus()
}
finally {
if (requestId === loadStatusRequestId)
isLoading.value = false
}
}

function openBuilds() {
router.push(`/app/${encodeURIComponent(props.appId)}/builds`)
}

function openDevices() {
router.push(`/app/${encodeURIComponent(props.appId)}/devices`)
}

watch(() => [props.appId, organizationStore.currentOrganization?.gid], () => {
void loadStatus()
}, { immediate: true })
</script>

<template>
<section
v-if="shouldShowBanner"
class="mb-4 overflow-hidden rounded-lg border border-amber-200 bg-white shadow-sm dark:border-amber-400/30 dark:bg-slate-900/95"
aria-live="polite"
>
<div class="flex flex-col gap-4 p-4 sm:p-5 lg:flex-row lg:items-center lg:justify-between">
<div class="flex min-w-0 gap-4">
<span class="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-amber-100 text-amber-700 ring-1 ring-amber-200 dark:bg-amber-400/15 dark:text-amber-200 dark:ring-amber-400/30">
<IconStore class="h-5 w-5" />
</span>
<div class="min-w-0">
<p class="text-sm font-semibold uppercase text-amber-700 dark:text-amber-200">
{{ t('store-release-onboarding-badge') }}
</p>
<h2 class="mt-1 text-xl font-semibold leading-snug text-slate-950 dark:text-white">
{{ title }}
</h2>
<p class="mt-2 max-w-3xl text-sm leading-6 text-slate-600 dark:text-slate-300">
{{ body }}
</p>

<ul class="mt-3 flex flex-col gap-2 text-sm text-slate-700 sm:flex-row sm:flex-wrap sm:items-center dark:text-slate-200">
<li class="inline-flex items-center gap-2">
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-400/15 dark:text-emerald-200">
<IconCheck class="h-3.5 w-3.5" />
</span>
{{ t('store-release-onboarding-step-live-update') }}
</li>
<li v-if="hasTestFlightDevice" class="inline-flex items-center gap-2">
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-sky-100 text-sky-700 dark:bg-sky-400/15 dark:text-sky-200">
<IconCheck class="h-3.5 w-3.5" />
</span>
{{ t('store-release-onboarding-step-testflight') }}
</li>
<li v-if="hasTrackUnknownDevice" class="inline-flex items-center gap-2">
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-sky-100 text-sky-700 dark:bg-sky-400/15 dark:text-sky-200">
<IconCircleDot class="h-3.5 w-3.5" />
</span>
{{ t('store-release-onboarding-step-track-unknown') }}
</li>
<li class="inline-flex items-center gap-2">
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-amber-100 text-amber-700 dark:bg-amber-400/15 dark:text-amber-200">
<IconCircleDot class="h-3.5 w-3.5" />
</span>
{{ t('store-release-onboarding-step-store-release') }}
</li>
</ul>
</div>
</div>

<div class="flex flex-col gap-2 sm:flex-row lg:shrink-0">
<button class="d-btn d-btn-primary min-h-11" type="button" @click="openBuilds">
<IconExternalLink class="h-4 w-4" />
{{ t('store-release-onboarding-builds') }}
</button>
<button class="d-btn d-btn-outline min-h-11" type="button" @click="openDevices">
<IconSmartphone class="h-4 w-4" />
{{ t('store-release-onboarding-devices') }}
</button>
</div>
</div>
</section>
</template>
2 changes: 2 additions & 0 deletions src/pages/app/[app].vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import DeploymentBanner from '~/components/dashboard/DeploymentBanner.vue'
import DeploymentStatsCard from '~/components/dashboard/DeploymentStatsCard.vue'
import DevicesStats from '~/components/dashboard/DevicesStats.vue'
import ReleaseBanner from '~/components/dashboard/ReleaseBanner.vue'
import StoreReleaseOnboardingBanner from '~/components/dashboard/StoreReleaseOnboardingBanner.vue'
import UpdateStatsCard from '~/components/dashboard/UpdateStatsCard.vue'
import { getCapgoVersion, useSupabase } from '~/services/supabase'
import { useDisplayStore } from '~/stores/display'
Expand Down Expand Up @@ -203,6 +204,7 @@ watchEffect(async () => {
</div>
</div>
</div>
<StoreReleaseOnboardingBanner v-if="!appNotFound && !showOnboardingBanner" :app-id="id" />
<DeploymentBanner v-if="!appNotFound" :app-id="id" @deployed="refreshData" />
<ReleaseBanner v-if="!appNotFound" :app-id="id" />
<CompatibilityBanner v-if="!appNotFound" :app-id="id" />
Expand Down
3 changes: 3 additions & 0 deletions src/types/supabase.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1364,6 +1364,7 @@ export type Database = {
default_channel: string | null
device_id: string
id: number
install_source: string | null
is_emulator: boolean | null
is_prod: boolean | null
key_id: string | null
Expand All @@ -1381,6 +1382,7 @@ export type Database = {
default_channel?: string | null
device_id: string
id?: never
install_source?: string | null
is_emulator?: boolean | null
is_prod?: boolean | null
key_id?: string | null
Expand All @@ -1398,6 +1400,7 @@ export type Database = {
default_channel?: string | null
device_id?: string
id?: never
install_source?: string | null
is_emulator?: boolean | null
is_prod?: boolean | null
key_id?: string | null
Expand Down
5 changes: 4 additions & 1 deletion supabase/functions/_backend/private/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ interface DataDevice {
versionName?: string
devicesId?: string[]
deviceIds?: string[] // TODO: remove when migration is done
installSources?: string[]
search?: string
customIdMode?: boolean
order?: Order[]
Expand All @@ -36,6 +37,7 @@ const devicesBodySchema = type({
'versionName?': safeQueryTextSchema,
'devicesId?': deviceIdSchema.array(),
'deviceIds?': deviceIdSchema.array(),
'installSources?': safeQueryTextSchema.array(),
'search?': safeQueryTextSchema,
'customIdMode?': 'boolean',
'order?': orderItemSchema.array(),
Expand Down Expand Up @@ -65,11 +67,12 @@ app.post('/', middlewareV2(['read', 'write', 'all', 'upload']), async (c) => {
}
const devicesIds = body.devicesId ?? body.deviceIds ?? []
if (body.count)
return c.json({ count: await countDevices(c, body.appId, body.customIdMode ?? false, devicesIds, body.versionName, body.search?.trim()) })
return c.json({ count: await countDevices(c, body.appId, body.customIdMode ?? false, devicesIds, body.versionName, body.search?.trim(), body.installSources) })
return c.json(await readDevices(c, {
app_id: body.appId,
version_name: body.versionName,
deviceIds: devicesIds,
installSources: body.installSources,
search: body.search,
order: body.order,
cursor: body.cursor,
Expand Down
5 changes: 3 additions & 2 deletions supabase/functions/_backend/public/device/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ interface publicDevice {
custom_id: string
is_prod: boolean
is_emulator: boolean
install_source: string | null
version_name: string | null
app_id: string
platform: Database['public']['Enums']['platform_os']
Expand All @@ -37,8 +38,8 @@ interface publicDevice {

export function filterDeviceKeys(devices: DeviceRes[]) {
return devices.map((device) => {
const { updated_at, device_id, custom_id, is_prod, is_emulator, version_name, version, app_id, platform, plugin_version, os_version, version_build, key_id } = device
return { updated_at, device_id, custom_id, is_prod, is_emulator, version_name, version, app_id, platform, plugin_version, os_version, version_build, key_id }
const { updated_at, device_id, custom_id, is_prod, is_emulator, install_source, version_name, version, app_id, platform, plugin_version, os_version, version_build, key_id } = device
return { updated_at, device_id, custom_id, is_prod, is_emulator, install_source, version_name, version, app_id, platform, plugin_version, os_version, version_build, key_id }
})
}

Expand Down
Loading
Loading