From 8c377b21383e5e0e05f528b3ff6943fc4c2f0ec9 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Mon, 15 Jun 2026 17:42:30 +0200 Subject: [PATCH] feat(onboarding): import organization from store listing --- messages/en.json | 17 +- .../dashboard/AppOnboardingFlow.vue | 398 +++++++++++------ src/pages/onboarding/organization.vue | 414 +++++++++++++----- .../_backend/private/store_metadata.ts | 13 + .../_backend/public/app/store_metadata.ts | 82 ++++ supabase/functions/private/index.ts | 2 + 6 files changed, 689 insertions(+), 237 deletions(-) create mode 100644 supabase/functions/_backend/private/store_metadata.ts diff --git a/messages/en.json b/messages/en.json index 51bd630b91..5dce302141 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1525,14 +1525,15 @@ "organization-onboarding-active-users-plus": "{count} active users", "organization-onboarding-active-users-up-to": "Up to {count} active users", "organization-onboarding-badge": "Get started", - "organization-onboarding-choice-hint": "Pick one path first. You can import the name and logo from a website or type the organization name manually.", + "organization-onboarding-choice-hint": "Pick one path first. You can import from a published app, import from a website, or type the organization name manually.", "organization-onboarding-continue-invite": "Continue to invite teammates", "organization-onboarding-continue-logo": "Continue to logo", "organization-onboarding-create-app": "Create app", "organization-onboarding-import-preview": "Imported preview", - "organization-onboarding-imported-logo-preview-alt": "Imported website logo preview", + "organization-onboarding-imported-logo-preview-alt": "Imported logo preview", + "organization-onboarding-import-store": "Import app details", "organization-onboarding-import-website": "Import organization details", - "organization-onboarding-imported-logo-failed": "Could not import logo from website", + "organization-onboarding-imported-logo-failed": "Could not import logo", "organization-onboarding-imported-logo-unavailable": "No imported logo available", "organization-onboarding-invite-empty-state": "No invitations sent yet.", "organization-onboarding-invite-subtitle": "Invite teammates now or finish onboarding and do it later from the members page.", @@ -1560,6 +1561,8 @@ "organization-onboarding-logo-title": "Add a logo", "organization-onboarding-mode-name": "Enter a name", "organization-onboarding-mode-name-helper": "Type the organization name now and add brand assets later.", + "organization-onboarding-mode-store": "Import from published app", + "organization-onboarding-mode-store-helper": "Use a store link to prefill the organization from the app developer.", "organization-onboarding-mode-required": "Choose how you want to start", "organization-onboarding-mode-website": "Import from website", "organization-onboarding-mode-website-helper": "Use the company website to prefill the name and logo.", @@ -1591,6 +1594,14 @@ "organization-onboarding-logo-tip-upload": "Upload your own asset if you want tighter brand control.", "organization-onboarding-upload-logo": "Upload logo", "organization-onboarding-use-imported-logo": "Use imported logo", + "organization-onboarding-store-fetch-failed": "Could not import app store details", + "organization-onboarding-store-help": "Paste an App Store or Google Play link and Capgo will use the developer name, app icon, and app ID when available.", + "organization-onboarding-store-imported": "Store import is done. Review the organization name before continuing.", + "organization-onboarding-store-invalid": "Enter a valid App Store or Google Play link", + "organization-onboarding-store-label": "App Store or Google Play link", + "organization-onboarding-store-name-helper": "Imported from the store listing developer. You can still edit the organization name before continuing.", + "organization-onboarding-store-name-helper-empty": "Run the store import first, or type the organization name yourself.", + "organization-onboarding-store-placeholder": "https://apps.apple.com/... or https://play.google.com/store/apps/details?id=com.example.app", "organization-onboarding-website-fetch-failed": "Could not import website assets", "organization-onboarding-website-help": "Enter your company website and Capgo will import the organization name and logo for you.", "organization-onboarding-website-imported": "Website import is done. Review the organization name before continuing.", diff --git a/src/components/dashboard/AppOnboardingFlow.vue b/src/components/dashboard/AppOnboardingFlow.vue index 67967deb64..f3c44c0d4c 100644 --- a/src/components/dashboard/AppOnboardingFlow.vue +++ b/src/components/dashboard/AppOnboardingFlow.vue @@ -2,7 +2,7 @@ import type { Database } from '~/types/supabase.types' import { FormKit } from '@formkit/vue' import { FunctionsHttpError } from '@supabase/supabase-js' -import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { useRoute, useRouter } from 'vue-router' import { toast } from 'vue-sonner' @@ -42,6 +42,31 @@ const config = getLocalConfig() type AppRow = Database['public']['Tables']['apps']['Row'] +interface StoreUrls { + iosStoreUrl: string | null + androidStoreUrl: string | null +} + +interface StoreMetadataResponse { + name?: unknown + icon_data_url?: unknown + icon_url?: unknown + screenshot_url?: unknown + app_id?: unknown +} + +interface AppCreateValues { + ownerOrg: string + appName: string + initialAppId: string + existingApp: boolean +} + +interface CreatedAppCandidate { + appId: string + responseData: AppRow +} + const isLoading = ref(true) const isSubmitting = ref(false) const isImportingStore = ref(false) @@ -191,7 +216,7 @@ function extractAndroidAppId(url: string) { } } -function getStoreUrls(url: string) { +function getStoreUrls(url: string): StoreUrls { if (!url) return { iosStoreUrl: null, androidStoreUrl: null } @@ -312,6 +337,58 @@ async function loadResumeApp() { return true } +function getInitialStoreUrlFromQuery() { + const value = route.query.store_url + return typeof value === 'string' ? value.trim() : '' +} + +async function applyStorePrefillFromQuery() { + const initialStoreUrl = getInitialStoreUrlFromQuery() + if (!initialStoreUrl) + return false + + existingApp.value = true + await nextTick() + existingAppSetup.value = 'import' + storeUrl.value = initialStoreUrl + await importStoreMetadata() + return true +} + +function getTrimmedString(value: unknown) { + return typeof value === 'string' ? value.trim() : '' +} + +function isCurrentStoreImportRun(requestedRun: number, requestedUrl: string) { + return requestedRun === storeImportRun + && existingAppSetup.value === 'import' + && storeUrl.value.trim() === requestedUrl +} + +function getImportedStoreIcon(data: StoreMetadataResponse) { + return getTrimmedString(data.icon_data_url) || getTrimmedString(data.icon_url) +} + +function applyImportedStoreMetadata(data: StoreMetadataResponse) { + importedStoreAppId.value = '' + storeIconPreview.value = '' + storeScreenshotPreview.value = '' + + const importedName = getTrimmedString(data.name) + if (importedName && !appName.value.trim()) + appName.value = importedName + + const importedIcon = getImportedStoreIcon(data) + if (importedIcon && !localIconPreview.value) + storeIconPreview.value = importedIcon + + const screenshotUrl = getTrimmedString(data.screenshot_url) + storeScreenshotPreview.value = screenshotUrl + + const importedAppId = getTrimmedString(data.app_id) + importedStoreAppId.value = importedAppId +} + async function importStoreMetadata() { const requestedUrl = storeUrl.value.trim() if (!requestedUrl || existingAppSetup.value !== 'import') @@ -325,31 +402,16 @@ async function importStoreMetadata() { body: { url: requestedUrl }, }) - if (requestedRun !== storeImportRun || existingAppSetup.value !== 'import' || storeUrl.value.trim() !== requestedUrl) + if (!isCurrentStoreImportRun(requestedRun, requestedUrl)) return if (error) throw error - if (typeof data?.name === 'string' && data.name.trim() && !appName.value.trim()) - appName.value = data.name.trim() - - const importedIcon = typeof data?.icon_data_url === 'string' && data.icon_data_url.trim() - ? data.icon_data_url.trim() - : typeof data?.icon_url === 'string' && data.icon_url.trim() - ? data.icon_url.trim() - : '' - if (importedIcon && !localIconPreview.value) - storeIconPreview.value = importedIcon - - if (typeof data?.screenshot_url === 'string' && data.screenshot_url.trim()) - storeScreenshotPreview.value = data.screenshot_url.trim() - - if (typeof data?.app_id === 'string' && data.app_id.trim()) - importedStoreAppId.value = data.app_id.trim() + applyImportedStoreMetadata((data ?? {}) as StoreMetadataResponse) } catch (error) { - if (requestedRun !== storeImportRun || existingAppSetup.value !== 'import' || storeUrl.value.trim() !== requestedUrl) + if (!isCurrentStoreImportRun(requestedRun, requestedUrl)) return console.error('Cannot import store metadata', error) @@ -361,13 +423,19 @@ async function importStoreMetadata() { } } -function onSelectIconFormKit(value: unknown) { +function resolveSelectedIconFile(value: unknown) { const fileValue = Array.isArray(value) ? value[0] : value - const file = fileValue && typeof fileValue === 'object' && 'file' in fileValue - ? (fileValue as { file?: File }).file ?? null - : fileValue instanceof File - ? fileValue - : null + if (fileValue instanceof File) + return fileValue + + if (fileValue && typeof fileValue === 'object' && 'file' in fileValue) + return (fileValue as { file?: File }).file ?? null + + return null +} + +function onSelectIconFormKit(value: unknown) { + const file = resolveSelectedIconFile(value) selectedIconFile.value = file if (localIconPreview.value.startsWith('blob:')) @@ -441,29 +509,43 @@ async function readFunctionError(error: unknown) { } } -async function uploadIcon(appId: string, iconSourceUrl?: string) { - if (!currentOrg.value?.gid) - return - - let fileToUpload = selectedIconFile.value +async function getRemoteIconFile(iconSourceUrl?: string) { + if (!iconSourceUrl) + return null - if (!fileToUpload && iconSourceUrl) { - try { - const parsedIconUrl = new URL(iconSourceUrl) - if (parsedIconUrl.protocol !== 'https:') { - console.warn('Skipping non-HTTPS icon URL', iconSourceUrl) + try { + const parsedIconUrl = new URL(iconSourceUrl) + if (parsedIconUrl.protocol === 'https:') { + const response = await fetch(parsedIconUrl.toString()) + if (!response.ok) { + console.warn('Remote icon fetch failed', response.status, parsedIconUrl.toString()) + return null } - else { - const response = await fetch(parsedIconUrl.toString()) - const blob = await response.blob() - fileToUpload = new File([blob], 'store-icon.png', { type: blob.type || 'image/png' }) + + const contentType = response.headers.get('content-type') || '' + if (!contentType.startsWith('image/')) { + console.warn('Remote icon is not an image', contentType) + return null } + + const blob = await response.blob() + return new File([blob], 'store-icon.png', { type: blob.type || 'image/png' }) } - catch (error) { - console.warn('Cannot fetch remote icon', error) - } + + console.warn('Skipping non-HTTPS icon URL', iconSourceUrl) + } + catch (error) { + console.warn('Cannot fetch remote icon', error) } + return null +} + +async function uploadIcon(appId: string, iconSourceUrl?: string) { + if (!currentOrg.value?.gid) + return + + const fileToUpload = selectedIconFile.value ?? await getRemoteIconFile(iconSourceUrl) if (!fileToUpload) return @@ -486,103 +568,144 @@ async function uploadIcon(appId: string, iconSourceUrl?: string) { .eq('app_id', appId) } -async function createAppRecord() { - if (!currentOrg.value?.gid) { +function getAppCreateValues(): AppCreateValues | null { + const ownerOrg = currentOrg.value?.gid + if (!ownerOrg) { toast.error(t('app-onboarding-toast-no-organization')) - return + return null } - if (existingApp.value === null) { + const existingAppValue = existingApp.value + if (existingAppValue === null) { toast.error(t('app-onboarding-toast-existing-required')) - return + return null } - if (!appName.value.trim()) { + const appNameValue = appName.value.trim() + if (!appNameValue) { toast.error(t('app-onboarding-toast-name-required')) - return + return null } - if (!generatedAppId.value.trim()) { + const initialAppId = generatedAppId.value.trim() + if (!initialAppId) { toast.error(t('app-onboarding-toast-appid-required')) + return null + } + + return { + ownerOrg, + appName: appNameValue, + initialAppId, + existingApp: existingAppValue, + } +} + +function getNormalizedStoreUrlsForCreate(existingAppValue: boolean): StoreUrls { + if (existingAppValue && existingAppSetup.value === 'import') + return getStoreUrls(storeUrl.value.trim()) + + return { iosStoreUrl: null, androidStoreUrl: null } +} + +async function createCandidateApp(values: AppCreateValues, candidateId: string, normalizedStoreUrls: StoreUrls) { + const { data, error } = await supabase.functions.invoke('app', { + method: 'POST', + body: { + owner_org: values.ownerOrg, + app_id: candidateId, + name: values.appName, + need_onboarding: true, + existing_app: values.existingApp, + ios_store_url: normalizedStoreUrls.iosStoreUrl, + android_store_url: normalizedStoreUrls.androidStoreUrl, + }, + }) + + if (!error && data?.app_id) + return data as AppRow + + const functionError = await readFunctionError(error) + const isConflict = isAppIdConflict({ + status: functionError?.status ?? (error as { status?: number } | null | undefined)?.status, + message: `${functionError?.code ?? ''} ${functionError?.message ?? (error as { message?: string } | null | undefined)?.message ?? ''}`, + }) + + if (isConflict) + return null + + appIdFeedback.value = functionError?.message ?? t('app-onboarding-toast-create-error') + toast.error(appIdFeedback.value) + throw error ?? new Error(appIdFeedback.value) +} + +function applyCreatedCandidateFeedback(candidateId: string, initialAppId: string) { + manualAppId.value = candidateId + if (candidateId === initialAppId) { + appIdFeedback.value = '' + appIdSuggestions.value = [] return } - isSubmitting.value = true - try { - const normalizedStoreUrls = existingApp.value === true && existingAppSetup.value === 'import' - ? getStoreUrls(storeUrl.value.trim()) - : { iosStoreUrl: null, androidStoreUrl: null } - - let appId = generatedAppId.value - let responseData: AppRow | null = null - const candidateIds = [appId, ...buildAlternativeAppIds(appId)] - - for (const candidateId of candidateIds) { - const { data, error } = await supabase.functions.invoke('app', { - method: 'POST', - body: { - owner_org: currentOrg.value.gid, - app_id: candidateId, - name: appName.value.trim(), - need_onboarding: true, - existing_app: existingApp.value, - ios_store_url: normalizedStoreUrls.iosStoreUrl, - android_store_url: normalizedStoreUrls.androidStoreUrl, - }, - }) - - if (!error && data?.app_id) { - responseData = data as AppRow - appId = candidateId - manualAppId.value = candidateId - if (candidateId !== candidateIds[0]) { - appIdFeedback.value = t('app-onboarding-appid-taken-switched', { - original: candidateIds[0], - replacement: candidateId, - }) - appIdSuggestions.value = buildAlternativeAppIds(candidateIds[0]) - toast.info(appIdFeedback.value) - } - else { - appIdFeedback.value = '' - appIdSuggestions.value = [] - } - break - } + appIdFeedback.value = t('app-onboarding-appid-taken-switched', { + original: initialAppId, + replacement: candidateId, + }) + appIdSuggestions.value = buildAlternativeAppIds(initialAppId) + toast.info(appIdFeedback.value) +} - const functionError = await readFunctionError(error) - const isConflict = isAppIdConflict({ - status: functionError?.status ?? (error as { status?: number } | null | undefined)?.status, - message: `${functionError?.code ?? ''} ${functionError?.message ?? (error as { message?: string } | null | undefined)?.message ?? ''}`, - }) +async function findCreatedAppCandidate(values: AppCreateValues, normalizedStoreUrls: StoreUrls): Promise { + const candidateIds = [values.initialAppId, ...buildAlternativeAppIds(values.initialAppId)] - if (isConflict) - continue + for (const candidateId of candidateIds) { + const responseData = await createCandidateApp(values, candidateId, normalizedStoreUrls) + if (!responseData) + continue - appIdFeedback.value = functionError?.message ?? t('app-onboarding-toast-create-error') - toast.error(appIdFeedback.value) - throw error ?? new Error(appIdFeedback.value) - } + applyCreatedCandidateFeedback(candidateId, values.initialAppId) + return { appId: candidateId, responseData } + } + + return null +} + +function showNoAppIdCandidate(initialAppId: string) { + appIdSuggestions.value = buildAlternativeAppIds(initialAppId) + appIdFeedback.value = t('app-onboarding-appid-taken-pick-another', { + appId: initialAppId, + }) + toast.error(appIdFeedback.value) +} - if (!responseData) { - appIdSuggestions.value = buildAlternativeAppIds(candidateIds[0]) - appIdFeedback.value = t('app-onboarding-appid-taken-pick-another', { - appId: candidateIds[0], - }) - toast.error(appIdFeedback.value) +async function finishCreatedAppRecord(candidate: CreatedAppCandidate) { + const importedIconSource = canUseStoreImportPreview.value ? storeIconPreview.value : '' + await uploadIcon(candidate.appId, importedIconSource) + const { data: refreshed } = await supabase + .from('apps') + .select() + .eq('app_id', candidate.appId) + .single() + + createdApp.value = refreshed ?? candidate.responseData + flowStep.value = 'choice' +} + +async function createAppRecord() { + const values = getAppCreateValues() + if (!values) + return + + isSubmitting.value = true + try { + const normalizedStoreUrls = getNormalizedStoreUrlsForCreate(values.existingApp) + const candidate = await findCreatedAppCandidate(values, normalizedStoreUrls) + if (!candidate) { + showNoAppIdCandidate(values.initialAppId) return } - const importedIconSource = canUseStoreImportPreview.value ? storeIconPreview.value : '' - await uploadIcon(appId, importedIconSource) - const { data: refreshed } = await supabase - .from('apps') - .select() - .eq('app_id', appId) - .single() - - createdApp.value = refreshed ?? responseData - flowStep.value = 'choice' + await finishCreatedAppRecord(candidate) } catch (error) { console.error('Cannot create onboarding app', error) @@ -677,8 +800,10 @@ onMounted(async () => { toast.error(t('app-onboarding-toast-apikey-error')) } const resumed = await loadResumeApp() - if (!resumed) + if (!resumed) { + await applyStorePrefillFromQuery() flowStep.value = 'details' + } } finally { isLoading.value = false @@ -690,8 +815,15 @@ onBeforeUnmount(() => { URL.revokeObjectURL(localIconPreview.value) }) +function getDefaultExistingAppSetup(value: boolean | null) { + if (value === false) + return 'manual' + + return null +} + watch(existingApp, (value) => { - existingAppSetup.value = value === true ? null : value === false ? 'manual' : null + existingAppSetup.value = getDefaultExistingAppSetup(value) if (value !== true) { resetStoreImportState() } @@ -837,15 +969,12 @@ watch(suggestedAppId, (value) => {

{{ t('app-onboarding-command-help') }}

-
npx @@ -857,7 +986,7 @@ watch(suggestedAppId, (value) => { -
+
@@ -1162,14 +1291,11 @@ watch(suggestedAppId, (value) => {
-
npx @@ -1181,7 +1307,7 @@ watch(suggestedAppId, (value) => { -
+
diff --git a/src/pages/onboarding/organization.vue b/src/pages/onboarding/organization.vue index 16281c2174..48ca2af596 100644 --- a/src/pages/onboarding/organization.vue +++ b/src/pages/onboarding/organization.vue @@ -15,6 +15,7 @@ import IconPencil from '~icons/lucide/pencil-line' import IconRefresh from '~icons/lucide/refresh-cw' import IconSmartphone from '~icons/lucide/smartphone' import IconSparkles from '~icons/lucide/sparkles' +import IconStore from '~icons/lucide/store' import IconUpload from '~icons/lucide/upload-cloud' import IconUserPlus from '~icons/lucide/user-plus' import IconUsers from '~icons/lucide/users-round' @@ -28,7 +29,7 @@ import { useMainStore } from '~/stores/main' import { useOrganizationStore } from '~/stores/organization' type OnboardingStep = 'details' | 'logo' | 'invite' -type OnboardingMode = 'website' | 'name' | null +type OnboardingMode = 'store' | 'website' | 'name' | null interface InviteTeammateModalRef { openDialog: () => void @@ -46,6 +47,15 @@ interface WebsitePreview { icon: string | null website: string } +interface StoreMetadata { + name?: string + icon_url?: string | null + icon_data_url?: string | null + developer_name?: string | null + developer_url?: string | null + app_id?: string | null + url?: string +} interface UserCountStop { value: number @@ -53,6 +63,12 @@ interface UserCountStop { planName: string } +interface OrganizationFormValues { + orgName: string + userCountStop: UserCountStop + intent: string +} + const route = useRoute() const router = useRouter() const { t } = useI18n() @@ -65,20 +81,24 @@ const { currentOrganization } = storeToRefs(organizationStore) const step = ref('details') const mode = ref(null) const websiteInput = ref('') +const storeUrlInput = ref('') const orgNameInput = ref('') const createdOrgId = ref('') const isSubmitting = ref(false) const isUploadingLogo = ref(false) const isLoadingWebsitePreview = ref(false) +const isLoadingStoreMetadata = ref(false) const isLoggingOut = ref(false) const selectedLogoPreview = ref('') const sentInvites = ref([]) const websitePreview = ref(null) +const storeMetadata = ref(null) const inviteModalRef = ref(null) const logoInputRef = useTemplateRef('logoInput') const isAdditionalOrgFlow = ref(false) const estimatedUsersIndex = ref(null) const config = getLocalConfig() +let storeMetadataRun = 0 // Org-level onboarding intent: what the user wants to do with Capgo first. // Persisted on the new org (orgs.onboarding jsonb, keyed by `intent`) by the @@ -109,7 +129,7 @@ const activeOrgId = computed(() => createdOrgId.value || '') const activeOrgName = computed(() => { if (currentOrganization.value?.gid === activeOrgId.value) return currentOrganization.value.name - return orgNameInput.value.trim() || websitePreview.value?.name || '' + return orgNameInput.value.trim() || storeMetadata.value?.developer_name?.trim() || websitePreview.value?.name || '' }) const hasSavedLogo = computed(() => currentOrganization.value?.gid === activeOrgId.value && !!currentOrganization.value.logo) const userCountStops = computed(() => { @@ -159,10 +179,35 @@ const websiteHostname = computed(() => { } }) -const importedLogoUrl = computed(() => websitePreview.value?.icon ?? '') +const storeHostname = computed(() => { + const value = storeMetadata.value?.url || storeUrlInput.value.trim() + if (!value) + return '' + + try { + const normalized = /^https?:\/\//.test(value) ? value : `https://${value}` + return new URL(normalized).hostname.replace(/^www\./, '') + } + catch { + return '' + } +}) + +const importedLogoUrl = computed(() => { + const storeIcon = storeMetadata.value?.icon_data_url || storeMetadata.value?.icon_url || '' + return websitePreview.value?.icon || storeIcon +}) +const importedLogoFilenameBase = computed(() => websiteHostname.value || storeMetadata.value?.app_id || storeHostname.value || 'imported-logo') +const sourceSummaryLabel = computed(() => { + if (mode.value === 'store') + return storeMetadata.value?.app_id || storeHostname.value || t('organization-onboarding-mode-store') + if (mode.value === 'website') + return websiteHostname.value || t('organization-onboarding-mode-website') + return t('organization-onboarding-mode-name') +}) const canShowOrgDetails = computed(() => mode.value !== null) const canCreateOrganization = computed(() => { - if (!main.auth || isSubmitting.value || isLoadingWebsitePreview.value || !mode.value) + if (!main.auth || isSubmitting.value || isLoadingWebsitePreview.value || isLoadingStoreMetadata.value || !mode.value) return false return !!orgNameInput.value.trim() && !!selectedUserCountStop.value @@ -250,7 +295,7 @@ function toTitleCaseSegment(segment: string) { } function deriveOrgNameFromWebsite(hostname: string) { - const primarySegment = hostname.split('.').filter(Boolean)[0] ?? '' + const primarySegment = hostname.split('.').find(Boolean) ?? '' return toTitleCaseSegment(primarySegment) } @@ -264,7 +309,7 @@ function isStepActive(stepId: OnboardingStep) { } async function goBack() { - if (window.history.length > 1) { + if (globalThis.history.length > 1) { await router.back() return } @@ -294,11 +339,20 @@ async function logoutFromOnboarding() { } } +function getCurrentStoreUrl() { + if (mode.value !== 'store') + return '' + + return storeMetadata.value?.url?.trim() || storeUrlInput.value.trim() +} + async function syncRouteQuery(nextStep: OnboardingStep, orgId = createdOrgId.value) { + const storeUrl = getCurrentStoreUrl() await router.replace({ path: '/onboarding/organization', query: { ...(orgId ? { org: orgId } : {}), + ...(storeUrl ? { store_url: storeUrl } : {}), ...(typeof route.query.source === 'string' ? { source: route.query.source } : {}), ...(typeof route.query.to === 'string' ? { to: route.query.to } : {}), step: nextStep, @@ -314,6 +368,12 @@ async function hydrateOnboardingFromQuery() { const queryOrgId = typeof route.query.org === 'string' ? route.query.org : '' const queryStep = typeof route.query.step === 'string' ? route.query.step as OnboardingStep : 'details' + const queryStoreUrl = typeof route.query.store_url === 'string' ? route.query.store_url.trim() : '' + + if (queryStoreUrl) { + mode.value = 'store' + storeUrlInput.value = queryStoreUrl + } const validatedOrg = queryOrgId ? organizationStore.organizations.find(org => org.gid === queryOrgId && !org.role.includes('invite')) @@ -365,102 +425,183 @@ async function fetchWebsitePreview() { } } +async function fetchStoreMetadata() { + if (mode.value !== 'store') + return null + + const storeUrl = storeUrlInput.value.trim() + if (!storeUrl) { + toast.error(t('organization-onboarding-store-invalid')) + return null + } + + const requestedRun = ++storeMetadataRun + isLoadingStoreMetadata.value = true + try { + const { data, error } = await supabase.functions.invoke('private/store_metadata', { + body: { + url: storeUrl, + }, + }) + + if (requestedRun !== storeMetadataRun || mode.value !== 'store' || storeUrlInput.value.trim() !== storeUrl) + return null + + if (error || !data) { + console.error('Failed to fetch store metadata', error) + toast.error(t('organization-onboarding-store-fetch-failed')) + return null + } + + const metadata = data as StoreMetadata + storeMetadata.value = metadata + + const developerName = metadata.developer_name?.trim() + const appName = metadata.name?.trim() + orgNameInput.value = developerName || orgNameInput.value.trim() || appName || '' + return storeMetadata.value + } + finally { + if (requestedRun === storeMetadataRun) + isLoadingStoreMetadata.value = false + } +} + function deriveNameFromWebsitePreview(hostname: string) { return deriveOrgNameFromWebsite(hostname || websiteHostname.value) } -async function createOrganization() { - if (isSubmitting.value || !main.auth) - return - +function getOrganizationFormValues(): OrganizationFormValues | null { if (!mode.value) { toast.error(t('organization-onboarding-mode-required')) - return + return null } const orgName = orgNameInput.value.trim() if (!orgName) { toast.error(t('org-name-required')) - return + return null } - if (!selectedUserCountStop.value) { + const userCountStop = selectedUserCountStop.value + if (!userCountStop) { toast.error(t('organization-onboarding-user-scale-required')) - return + return null } - if (!selectedIntent.value) { + const intent = selectedIntent.value + if (!intent) { toast.error(t('organization-onboarding-intent-required')) - return + return null } - isSubmitting.value = true + return { orgName, userCountStop, intent } +} - try { - const normalizedWebsite = mode.value === 'website' - ? websitePreview.value?.website - : undefined +function getNormalizedOrganizationWebsite() { + if (mode.value === 'website') + return websitePreview.value?.website - const { data, error } = await supabase.functions.invoke('organization', { - method: 'POST', - body: { - name: orgName, - email: main.auth.email ?? '', - estimatedMau: selectedUserCountStop.value.value, - website: normalizedWebsite, - intent: selectedIntent.value, - }, + if (mode.value === 'store') + return storeMetadata.value?.developer_url ?? undefined + + return undefined +} + +async function createOrganizationRecord(form: OrganizationFormValues) { + const { data, error } = await supabase.functions.invoke('organization', { + method: 'POST', + body: { + name: form.orgName, + email: main.auth?.email ?? '', + estimatedMau: form.userCountStop.value, + website: getNormalizedOrganizationWebsite(), + intent: form.intent, + }, + }) + + if (error || !data?.id) { + console.error('Error creating organization during onboarding', error) + toast.error(error?.code === '23505' + ? t('org-with-this-name-exists') + : t('cannot-create-org')) + return '' + } + + return data.id as string +} + +function trackCreatedOrganization(orgId: string, form: OrganizationFormValues) { + try { + pushEvent('onboarding_intent_selected', config.supaHost, { + intent: form.intent, + estimated_mau: form.userCountStop.value, + org_id: orgId, }) + } + catch (error) { + console.error('Failed to track onboarding intent', error) + } +} - if (error || !data?.id) { - console.error('Error creating organization during onboarding', error) - toast.error(error?.code === '23505' - ? t('org-with-this-name-exists') - : t('cannot-create-org')) - return - } +async function refreshCreatedOrganization(orgId: string) { + try { + await organizationStore.fetchOrganizations() + organizationStore.setCurrentOrganization(orgId) + } + catch (error) { + console.error('Failed to refresh organizations after onboarding create', error) + toast.error(t('organization-onboarding-refresh-failed')) + } +} - createdOrgId.value = data.id - toast.success(t('org-created-successfully')) +async function tryUseImportedLogoAfterCreate() { + if (!importedLogoUrl.value) + return false - try { - pushEvent('onboarding_intent_selected', config.supaHost, { - intent: selectedIntent.value, - estimated_mau: selectedUserCountStop.value?.value ?? null, - org_id: data.id, - }) - } - catch (error) { - console.error('Failed to track onboarding intent', error) - } + try { + return await useImportedLogo() + } + catch (error) { + console.error('Failed to import logo after organization create', error) + return false + } +} - try { - await organizationStore.fetchOrganizations() - organizationStore.setCurrentOrganization(data.id) - } - catch (error) { - console.error('Failed to refresh organizations after onboarding create', error) - toast.error(t('organization-onboarding-refresh-failed')) - } +async function continueToLogoStep(orgId: string) { + step.value = 'logo' + try { + await syncRouteQuery('logo', orgId) + } + catch (error) { + console.error('Failed to sync onboarding route after create', error) + } +} - if (mode.value === 'website' && importedLogoUrl.value) { - try { - const imported = await useImportedLogo() - if (imported) - return - } - catch (error) { - console.error('Failed to import logo after organization create', error) - } - } +async function createOrganization() { + if (isSubmitting.value || !main.auth) + return - step.value = 'logo' - try { - await syncRouteQuery('logo', data.id) - } - catch (error) { - console.error('Failed to sync onboarding route after create', error) - } + const form = getOrganizationFormValues() + if (!form) + return + + isSubmitting.value = true + + try { + const orgId = await createOrganizationRecord(form) + if (!orgId) + return + + createdOrgId.value = orgId + toast.success(t('org-created-successfully')) + trackCreatedOrganization(orgId, form) + await refreshCreatedOrganization(orgId) + + if (await tryUseImportedLogoAfterCreate()) + return + + await continueToLogoStep(orgId) } finally { isSubmitting.value = false @@ -471,7 +612,7 @@ async function uploadLogoBlob(blob: Blob, filename?: string) { const orgId = activeOrgId.value if (!orgId) { toast.error(t('organization-not-found')) - return + return false } isUploadingLogo.value = true @@ -480,10 +621,12 @@ async function uploadLogoBlob(blob: Blob, filename?: string) { step.value = 'invite' toast.success(t('organization-onboarding-logo-saved')) await syncRouteQuery('invite', orgId) + return true } catch (error) { console.error('Failed to upload organization logo during onboarding', error) toast.error(t('something-went-wrong-try-again-later')) + return false } finally { isUploadingLogo.value = false @@ -506,10 +649,9 @@ async function useImportedLogo() { } const binary = atob(payload) - const bytes = Uint8Array.from(binary, char => char.charCodeAt(0)) + const bytes = Uint8Array.from(binary, char => char.codePointAt(0) ?? 0) const blob = new Blob([bytes], { type: contentType }) - await uploadLogoBlob(blob, `${websiteHostname.value || 'website-logo'}.png`) - return true + return await uploadLogoBlob(blob, `${importedLogoFilenameBase.value}.png`) } const response = await fetch(importedLogoUrl.value) @@ -519,8 +661,7 @@ async function useImportedLogo() { return false } const blob = await response.blob() - await uploadLogoBlob(blob, `${websiteHostname.value || 'website-logo'}.png`) - return true + return await uploadLogoBlob(blob, `${importedLogoFilenameBase.value}.png`) } catch (error) { console.error('Failed to fetch imported logo', error) @@ -572,7 +713,17 @@ async function finishOnboarding() { console.error('Failed to refresh organizations before finishing onboarding', error) } - await router.push('/app/new') + const storeUrl = getCurrentStoreUrl() + + await router.push({ + path: '/app/new', + query: storeUrl + ? { + existing_app: '1', + store_url: storeUrl, + } + : {}, + }) } watch(() => route.query.step, (nextValue) => { @@ -603,6 +754,12 @@ watch([websiteInput, mode], () => { websitePreview.value = null }) +watch([storeUrlInput, mode], () => { + storeMetadataRun += 1 + storeMetadata.value = null + isLoadingStoreMetadata.value = false +}) + onMounted(async () => { if (!main.auth) { await router.replace('/login?to=/onboarding/organization') @@ -623,7 +780,9 @@ onUnmounted(() => {
+ {
-
+
+
-
+
+
+ +
+ + +
+

+ {{ storeMetadata + ? t('organization-onboarding-store-imported') + : t('organization-onboarding-store-help') }} +

+
+
+ +
@@ -943,7 +1159,7 @@ onUnmounted(() => { {{ isCompactCreateOrgFlow ? t('organization-create-submit') - : mode === 'website' && importedLogoUrl + : importedLogoUrl ? t('organization-onboarding-continue-invite') : t('organization-onboarding-continue-logo') }} @@ -1122,7 +1338,7 @@ onUnmounted(() => { {{ activeOrgName || t('organization-onboarding-org-placeholder') }}

- {{ websiteHostname || t('organization-onboarding-mode-name') }} + {{ sourceSummaryLabel }}

@@ -1133,11 +1349,13 @@ onUnmounted(() => { {{ t('organization-onboarding-selected-path') }}
- {{ mode === 'website' - ? t('organization-onboarding-mode-website') - : mode === 'name' - ? t('organization-onboarding-mode-name') - : t('organization-onboarding-no-choice') }} + {{ mode === 'store' + ? t('organization-onboarding-mode-store') + : mode === 'website' + ? t('organization-onboarding-mode-website') + : mode === 'name' + ? t('organization-onboarding-mode-name') + : t('organization-onboarding-no-choice') }}
@@ -1159,19 +1377,19 @@ onUnmounted(() => {
  • - {{ mode === 'website' && importedLogoUrl + {{ importedLogoUrl ? t('organization-onboarding-next-invite-direct') : t('organization-onboarding-next-logo') }}
  • - {{ mode === 'website' && importedLogoUrl + {{ importedLogoUrl ? t('organization-onboarding-next-create-app-direct') : t('organization-onboarding-next-invite') }}
  • - {{ mode === 'website' && importedLogoUrl + {{ importedLogoUrl ? t('organization-onboarding-next-assets-direct') : t('organization-onboarding-next-create-app') }}
  • @@ -1197,7 +1415,7 @@ onUnmounted(() => { {{ activeOrgName || t('organization-onboarding-org-placeholder') }}

    - {{ websiteHostname || t('organization-onboarding-mode-name') }} + {{ sourceSummaryLabel }}

diff --git a/supabase/functions/_backend/private/store_metadata.ts b/supabase/functions/_backend/private/store_metadata.ts new file mode 100644 index 0000000000..727d10452c --- /dev/null +++ b/supabase/functions/_backend/private/store_metadata.ts @@ -0,0 +1,13 @@ +import type { FetchStoreMetadataBody } from '../public/app/store_metadata.ts' +import { fetchStoreMetadata } from '../public/app/store_metadata.ts' +import { createHono, middlewareAuth, parseBody, useCors } from '../utils/hono.ts' +import { version } from '../utils/version.ts' + +export const app = createHono('', version) + +app.use('/', useCors) + +app.post('/', middlewareAuth, async (c) => { + const body = await parseBody(c) + return fetchStoreMetadata(c, body) +}) diff --git a/supabase/functions/_backend/public/app/store_metadata.ts b/supabase/functions/_backend/public/app/store_metadata.ts index 86d2a7fcbc..87199d4895 100644 --- a/supabase/functions/_backend/public/app/store_metadata.ts +++ b/supabase/functions/_backend/public/app/store_metadata.ts @@ -12,6 +12,14 @@ interface AppleLookupResult { artworkUrl100?: string bundleId?: string screenshotUrls?: string[] + artistName?: string + sellerName?: string + artistViewUrl?: string +} + +interface JsonLdAuthor { + name: string + url: string } const ALLOWED_STORE_HOSTS = new Set([ @@ -129,6 +137,72 @@ function extractTitle(html: string) { return titleMatch?.[1] ? decodeHtml(titleMatch[1]) : '' } +function normalizeJsonLdEntries(parsed: unknown) { + return Array.isArray(parsed) ? parsed : [parsed] +} + +function getJsonLdAuthor(entry: unknown): JsonLdAuthor | null { + if (!entry || typeof entry !== 'object' || !('author' in entry)) + return null + + const author = (entry as { author?: unknown }).author + if (!author || typeof author !== 'object') + return null + + const candidate = author as { name?: unknown, url?: unknown } + const jsonLdAuthor = { + name: typeof candidate.name === 'string' ? decodeHtml(candidate.name).trim() : '', + url: typeof candidate.url === 'string' ? decodeHtml(candidate.url).trim() : '', + } + + return jsonLdAuthor.name || jsonLdAuthor.url ? jsonLdAuthor : null +} + +function parseJsonLdAuthor(rawJson: string) { + try { + const parsed = JSON.parse(rawJson) + for (const entry of normalizeJsonLdEntries(parsed)) { + const author = getJsonLdAuthor(entry) + if (author) + return author + } + } + catch { + // Store pages change often; keep parsing best-effort and fall back below. + } + + return null +} + +function extractJsonLdAuthor(html: string) { + const scriptPattern = /]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi + let match: RegExpExecArray | null + + while ((match = scriptPattern.exec(html)) !== null) { + const rawJson = decodeHtml(match[1]?.trim() ?? '') + if (!rawJson) + continue + + const author = parseJsonLdAuthor(rawJson) + if (author) + return author + } + + return { name: '', url: '' } +} + +function extractGooglePlayDeveloperName(html: string, author: JsonLdAuthor | null) { + if (author?.name) + return author.name + + const developerLinkMatch = /developer\?id=[^"']*["'][^>]*>\s*]*>([^<]+)<\/span>/i.exec(html) + return developerLinkMatch?.[1] ? decodeHtml(developerLinkMatch[1]).trim() : '' +} + +function extractDeveloperUrl(html: string, author: JsonLdAuthor | null) { + return author?.url || extractMetaTag(html, 'appstore:developer_url') +} + function decodeHtml(value: string) { return value .replace(/"/g, '"') @@ -189,6 +263,12 @@ export async function fetchStoreMetadata(c: Context, bod const screenshot_url = appleLookup?.screenshotUrls?.[0]?.trim() || null const app_id = android_app_id || ios_bundle_id const name = appleLookup?.trackName?.trim() || normalizeStoreName(scrapedName, parsedUrl) + const jsonLdAuthor = appleLookup ? null : extractJsonLdAuthor(html) + const developer_name = appleLookup?.sellerName?.trim() + || appleLookup?.artistName?.trim() + || extractGooglePlayDeveloperName(html, jsonLdAuthor) + || null + const developer_url = appleLookup?.artistViewUrl?.trim() || extractDeveloperUrl(html, jsonLdAuthor) || null const icon_url = appleLookup?.artworkUrl512?.trim() || appleLookup?.artworkUrl100?.trim() || scrapedIconUrl const icon_data_url = await fetchIconDataUrl(icon_url) @@ -198,6 +278,8 @@ export async function fetchStoreMetadata(c: Context, bod icon_url, icon_data_url, screenshot_url, + developer_name, + developer_url, app_id, android_app_id, ios_bundle_id, diff --git a/supabase/functions/private/index.ts b/supabase/functions/private/index.ts index 711b1a739b..60b1cc09dc 100644 --- a/supabase/functions/private/index.ts +++ b/supabase/functions/private/index.ts @@ -31,6 +31,7 @@ import { app as sso_provision_user } from '../_backend/private/sso/provision-use import { app as sso_sp_metadata } from '../_backend/private/sso/sp-metadata.ts' import { app as sso_verify_dns } from '../_backend/private/sso/verify-dns.ts' import { app as stats_priv } from '../_backend/private/stats.ts' +import { app as store_metadata } from '../_backend/private/store_metadata.ts' import { app as storeTop } from '../_backend/private/store_top.ts' import { app as stripe_checkout } from '../_backend/private/stripe_checkout.ts' import { app as stripe_portal } from '../_backend/private/stripe_portal.ts' @@ -76,6 +77,7 @@ appGlobal.route('/invite_existing_user_to_org', invite_existing_user_to_org) appGlobal.route('/accept_invitation', accept_invitation) appGlobal.route('/validate_password_compliance', validate_password_compliance) appGlobal.route('/verify_email_otp', verify_email_otp) +appGlobal.route('/store_metadata', store_metadata) appGlobal.route('/website_preview', website_preview) appGlobal.route('/sso/check-domain', sso_check_domain) appGlobal.route('/sso/check-enforcement', sso_check_enforcement)