feat(onboarding): require connecting a social network before checkout

Add a connect step between persona selection and Stripe checkout: persona ->
connect >=1 network (server-enforced) -> checkout. Extract the network grid into
a shared NetworkConnectGrid used by both onboarding and the accounts page, which
is redesigned from a table into the same grid (one account per network, with
per-card connect / connected+disconnect / reconnect states). Drop the openDialog
auto-open flow now that networks are shown inline.
This commit is contained in:
Paulo Castellano 2026-06-22 12:31:44 -03:00
parent 2f314c9a8c
commit 09d4e9d6b1
20 changed files with 736 additions and 619 deletions

View file

@ -6,8 +6,10 @@
use App\Actions\Billing\StartSubscriptionCheckout;
use App\Enums\Plan\Slug;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\User\Persona;
use App\Http\Requests\App\Onboarding\StoreOnboardingRequest;
use App\Http\Resources\App\SocialAccountResource;
use App\Models\Account;
use App\Models\Plan;
use App\Services\PostHogService;
@ -37,19 +39,15 @@ public function index(Request $request): Response|RedirectResponse
]);
}
public function store(
StoreOnboardingRequest $request,
PostHogService $postHog,
StartSubscriptionCheckout $checkout,
): SymfonyResponse|RedirectResponse {
public function store(StoreOnboardingRequest $request, PostHogService $postHog): RedirectResponse
{
if (config('trypost.self_hosted')) {
return redirect()->route('app.calendar');
}
$user = $request->user();
$account = $user->account;
if ($account?->subscribed(Account::SUBSCRIPTION_NAME)) {
if ($user->account?->subscribed(Account::SUBSCRIPTION_NAME)) {
return redirect()->route('app.calendar');
}
@ -61,12 +59,73 @@ public function store(
'persona' => $persona,
]);
return redirect()->route('app.onboarding.connect');
}
public function connect(Request $request): Response|RedirectResponse
{
if (config('trypost.self_hosted')) {
return redirect()->route('app.calendar');
}
$user = $request->user();
if ($user->account?->subscribed(Account::SUBSCRIPTION_NAME)) {
return redirect()->route('app.calendar');
}
if (! $user->persona) {
return redirect()->route('app.onboarding');
}
$workspace = $user->currentWorkspace;
if (! $workspace) {
return redirect()->route('app.workspaces.create');
}
$accounts = $workspace->socialAccounts()->orderBy('id')->get();
$platforms = collect(SocialPlatform::enabled())->map(fn (SocialPlatform $platform): array => [
'value' => $platform->value,
'label' => $platform->label(),
'color' => $platform->color(),
'network' => $platform->network(),
])->values();
return Inertia::render('onboarding/Connect', [
'platforms' => $platforms,
'accounts' => SocialAccountResource::collection($accounts)->resolve(),
]);
}
public function checkout(Request $request, StartSubscriptionCheckout $checkout): SymfonyResponse|RedirectResponse
{
if (config('trypost.self_hosted')) {
return redirect()->route('app.calendar');
}
$user = $request->user();
$account = $user->account;
if ($account?->subscribed(Account::SUBSCRIPTION_NAME)) {
return redirect()->route('app.calendar');
}
$workspace = $user->currentWorkspace;
if (! $workspace || ! $workspace->socialAccounts()->exists()) {
return redirect()->route('app.onboarding.connect')
->with('flash.banner', __('onboarding.connect.must_connect'))
->with('flash.bannerStyle', 'danger');
}
$plan = Plan::where('slug', Slug::Workspace)->firstOrFail();
return $checkout->redirect(
$account,
(string) $plan->stripe_monthly_price_id,
route('app.onboarding'),
route('app.onboarding.connect'),
);
}
}

View file

@ -114,7 +114,7 @@ public function store(StoreWorkspaceRequest $request, LogoAttacher $logoAttacher
}
}
return redirect()->route('app.accounts', ['openDialog' => 'true'])
return redirect()->route('app.accounts')
->with('success', __('workspaces.create.success'));
}

View file

@ -43,32 +43,19 @@ public function index(Request $request): Response|RedirectResponse
$this->authorize('view', $workspace);
$accounts = $workspace->socialAccounts()
->when(
$request->input('search'),
fn ($query, $search) => $query->where(function ($q) use ($search): void {
$q->where('display_name', 'ilike', "%{$search}%")
->orWhere('username', 'ilike', "%{$search}%")
->orWhere('platform', 'ilike', "%{$search}%");
}),
)
->orderBy('id')
->paginate(config('app.pagination.default'));
$platforms = collect(SocialPlatform::enabled())->map(fn ($platform) => [
'value' => $platform->value,
'label' => $platform->label(),
'color' => $platform->color(),
'network' => $platform->network(),
])->values();
return Inertia::render('accounts/Index', [
'workspace' => $workspace,
'accounts' => Inertia::scroll(fn () => SocialAccountResource::collection($accounts)),
'platforms' => $platforms,
'filters' => [
'search' => $request->input('search', ''),
],
'openDialog' => $request->boolean('openDialog'),
'connectedAccounts' => SocialAccountResource::collection(
$workspace->socialAccounts()->orderBy('id')->get(),
)->resolve(),
]);
}
@ -204,7 +191,7 @@ protected function forgetSocialConnectSession(): void
protected function getRedirectRoute(): string
{
return session('social_connect_onboarding', false) ? 'onboarding.connect' : 'accounts';
return session('social_connect_onboarding', false) ? 'app.onboarding.connect' : 'app.accounts';
}
/**

View file

@ -4,38 +4,16 @@
'title' => 'Connections',
'page_title' => 'Social Accounts',
'description' => 'Overview of all your connected social accounts',
'add_social' => 'Add Social',
'add_social_title' => 'Connect a Social Account',
'add_social_description' => 'Connect a social account to TryPost to start posting',
'connect_cta' => 'Connect',
'no_accounts' => 'No accounts connected yet',
'no_accounts_description' => 'Connect your social networks to start scheduling and publishing posts',
'no_search_results' => 'No accounts match your search',
'try_different_search' => 'Try a different keyword or clear the search.',
'search' => 'Search accounts...',
'added' => 'Added :date',
'not_connected' => 'Not connected',
'connect' => 'Connect',
'connection_lost' => 'Connection lost',
'reconnect' => 'Reconnect',
'reconnect_account' => 'Reconnect account',
'view_profile' => 'View profile',
'disconnect' => 'Disconnect',
'table' => [
'account' => 'Account',
'platform' => 'Platform',
'status' => 'Status',
'last_used' => 'Last used',
'added' => 'Added',
'active' => 'Active',
],
'never_used' => 'Never used',
'status' => [
'connected' => 'Connected',
'disconnected' => 'Disconnected',
],
'descriptions' => [
'linkedin' => 'Connect your LinkedIn personal profile',
'linkedin-page' => 'Connect a LinkedIn company page',

View file

@ -6,7 +6,6 @@
'title' => 'Welcome to TryPost',
'description' => 'Tell us what best describes you so we can tailor your experience.',
'continue' => 'Continue',
'trial_note' => 'Try it free for 7 days, no commitment. You won\'t be charged during your trial and can cancel anytime.',
'personas' => [
'creator' => 'Creator',
'freelancer' => 'Freelancer',
@ -15,4 +14,9 @@
'small_business' => 'Small business',
'other' => 'Other',
],
'connect' => [
'title' => 'Connect your first network',
'description' => 'Link at least one social account to start scheduling. You can add more anytime.',
'must_connect' => 'Connect at least one network to continue.',
],
];

View file

@ -4,38 +4,16 @@
'title' => 'Conexiones',
'page_title' => 'Cuentas Sociales',
'description' => 'Resumen de todas tus cuentas sociales conectadas',
'add_social' => 'Agregar Red Social',
'add_social_title' => 'Conectar una Cuenta Social',
'add_social_description' => 'Conecta una cuenta social a TryPost para empezar a publicar',
'connect_cta' => 'Conectar',
'no_accounts' => 'No hay cuentas conectadas todavía',
'no_accounts_description' => 'Conecta tus redes sociales para empezar a programar y publicar posts',
'no_search_results' => 'Ninguna cuenta coincide con tu búsqueda',
'try_different_search' => 'Prueba otra palabra clave o limpia la búsqueda.',
'search' => 'Buscar cuentas...',
'added' => 'Agregada :date',
'not_connected' => 'No conectado',
'connect' => 'Conectar',
'connection_lost' => 'Conexión perdida',
'reconnect' => 'Reconectar',
'reconnect_account' => 'Reconectar cuenta',
'view_profile' => 'Ver perfil',
'disconnect' => 'Desconectar',
'table' => [
'account' => 'Cuenta',
'platform' => 'Plataforma',
'status' => 'Estado',
'last_used' => 'Último uso',
'added' => 'Añadida',
'active' => 'Activa',
],
'never_used' => 'Nunca usada',
'status' => [
'connected' => 'Conectada',
'disconnected' => 'Desconectada',
],
'descriptions' => [
'linkedin' => 'Conecta tu perfil personal de LinkedIn',
'linkedin-page' => 'Conecta una página de empresa de LinkedIn',

View file

@ -6,7 +6,6 @@
'title' => 'Bienvenido a TryPost',
'description' => 'Cuéntanos qué te describe mejor para personalizar tu experiencia.',
'continue' => 'Continuar',
'trial_note' => 'Pruébalo gratis por 7 días, sin compromiso. No se te cobrará durante la prueba y puedes cancelar cuando quieras.',
'personas' => [
'creator' => 'Creador',
'freelancer' => 'Freelancer',
@ -15,4 +14,9 @@
'small_business' => 'Pequeña empresa',
'other' => 'Otro',
],
'connect' => [
'title' => 'Conecta tu primera red',
'description' => 'Vincula al menos una cuenta social para empezar a programar. Puedes añadir más cuando quieras.',
'must_connect' => 'Conecta al menos una red para continuar.',
],
];

View file

@ -4,38 +4,16 @@
'title' => 'Conexões',
'page_title' => 'Contas Sociais',
'description' => 'Visão geral de todas as suas contas sociais conectadas',
'add_social' => 'Adicionar Rede Social',
'add_social_title' => 'Conectar uma Conta Social',
'add_social_description' => 'Conecte uma conta social ao TryPost para começar a publicar',
'connect_cta' => 'Conectar',
'no_accounts' => 'Nenhuma conta conectada ainda',
'no_accounts_description' => 'Conecte suas redes sociais para começar a agendar e publicar posts',
'no_search_results' => 'Nenhuma conta corresponde à sua busca',
'try_different_search' => 'Tente outra palavra-chave ou limpe a busca.',
'search' => 'Buscar contas...',
'added' => 'Adicionada :date',
'not_connected' => 'Não conectado',
'connect' => 'Conectar',
'connection_lost' => 'Conexão perdida',
'reconnect' => 'Reconectar',
'reconnect_account' => 'Reconectar conta',
'view_profile' => 'Ver perfil',
'disconnect' => 'Desconectar',
'table' => [
'account' => 'Conta',
'platform' => 'Plataforma',
'status' => 'Status',
'last_used' => 'Último uso',
'added' => 'Adicionada',
'active' => 'Ativa',
],
'never_used' => 'Nunca usada',
'status' => [
'connected' => 'Conectada',
'disconnected' => 'Desconectada',
],
'descriptions' => [
'linkedin' => 'Conecte seu perfil pessoal do LinkedIn',
'linkedin-page' => 'Conecte uma página de empresa do LinkedIn',

View file

@ -6,7 +6,6 @@
'title' => 'Bem-vindo ao TryPost',
'description' => 'Conte o que melhor te descreve para personalizarmos sua experiência.',
'continue' => 'Continuar',
'trial_note' => 'Experimente grátis por 7 dias, sem compromisso. Você não será cobrado durante o teste e pode cancelar quando quiser.',
'personas' => [
'creator' => 'Criador',
'freelancer' => 'Freelancer',
@ -15,4 +14,9 @@
'small_business' => 'Pequena empresa',
'other' => 'Outro',
],
'connect' => [
'title' => 'Conecte sua primeira rede',
'description' => 'Vincule pelo menos uma conta social para começar a agendar. Você pode adicionar mais quando quiser.',
'must_connect' => 'Conecte pelo menos uma rede para continuar.',
],
];

View file

@ -1,208 +0,0 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { IconPlus } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { ref } from 'vue';
import TelegramConnectDialog from '@/components/accounts/TelegramConnectDialog.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useOAuthPopup } from '@/composables/useOAuthPopup';
import { Platform } from '@/types/platform';
export interface AvailablePlatform {
value: string;
label: string;
color: string;
}
defineProps<{
platforms: AvailablePlatform[];
}>();
const open = defineModel<boolean>('open', { default: false });
const getPlatformDescription = (platform: string): string =>
trans(`accounts.descriptions.${platform}`);
// Mirrors `NetworksGrid.vue` from the marketing site pastel tile bg
// + ink 2px border + slight rotation per platform, real PNG logo inside.
// `linkedin-page` / `instagram-facebook` fall back to the base brand
// image and same color since they're variants of the same network.
const platformTheme: Record<
string,
{ bg: string; rotate: string; image: string }
> = {
instagram: {
bg: 'bg-pink-200',
rotate: '-rotate-2',
image: '/images/accounts/instagram.png',
},
'instagram-facebook': {
bg: 'bg-pink-200',
rotate: '-rotate-2',
image: '/images/accounts/instagram.png',
},
facebook: {
bg: 'bg-sky-200',
rotate: 'rotate-1',
image: '/images/accounts/facebook.png',
},
linkedin: {
bg: 'bg-blue-200',
rotate: '-rotate-1',
image: '/images/accounts/linkedin.png',
},
'linkedin-page': {
bg: 'bg-blue-200',
rotate: '-rotate-1',
image: '/images/accounts/linkedin.png',
},
x: {
bg: 'bg-amber-200',
rotate: 'rotate-2',
image: '/images/accounts/x.png',
},
tiktok: {
bg: 'bg-fuchsia-200',
rotate: '-rotate-1',
image: '/images/accounts/tiktok.png',
},
youtube: {
bg: 'bg-red-200',
rotate: 'rotate-1',
image: '/images/accounts/youtube.png',
},
pinterest: {
bg: 'bg-rose-200',
rotate: '-rotate-2',
image: '/images/accounts/pinterest.png',
},
threads: {
bg: 'bg-emerald-200',
rotate: 'rotate-2',
image: '/images/accounts/threads.png',
},
bluesky: {
bg: 'bg-cyan-200',
rotate: '-rotate-1',
image: '/images/accounts/bluesky.png',
},
mastodon: {
bg: 'bg-violet-200',
rotate: 'rotate-1',
image: '/images/accounts/mastodon.png',
},
telegram: {
bg: 'bg-sky-200',
rotate: '-rotate-2',
image: '/images/accounts/telegram.png',
},
discord: {
bg: 'bg-indigo-200',
rotate: 'rotate-1',
image: '/images/accounts/discord.png',
},
};
const themeFor = (value: string) =>
platformTheme[value] ?? { bg: 'bg-muted', rotate: '', image: '' };
const telegramOpen = ref(false);
const { openOAuthPopup } = useOAuthPopup(() => {
open.value = false;
router.reload();
});
const connectPlatform = (platformValue: string) => {
open.value = false;
if (platformValue === Platform.Telegram) {
telegramOpen.value = true;
return;
}
openOAuthPopup(platformValue);
};
</script>
<template>
<div>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-5xl">
<DialogHeader>
<DialogTitle>{{
$t('accounts.add_social_title')
}}</DialogTitle>
<DialogDescription>
{{ $t('accounts.add_social_description') }}
</DialogDescription>
</DialogHeader>
<div
class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5"
>
<div
v-for="platform in platforms"
:key="platform.value"
class="group relative flex flex-col items-center gap-3 rounded-xl border-2 border-foreground bg-card p-4 text-center shadow-xs transition-shadow hover:shadow-md"
>
<span
class="pointer-events-none absolute -top-2 -right-2 inline-flex size-6 items-center justify-center rounded-full border-2 border-foreground bg-violet-200 text-foreground opacity-0 shadow-2xs transition-all group-hover:scale-110 group-hover:rotate-90 group-hover:opacity-100"
aria-hidden="true"
>
<IconPlus class="size-3.5" stroke-width="3" />
</span>
<div
:class="[
themeFor(platform.value).bg,
themeFor(platform.value).rotate,
'inline-flex size-16 items-center justify-center rounded-2xl border-2 border-foreground shadow-sm transition-transform group-hover:!rotate-0',
]"
>
<img
:src="themeFor(platform.value).image"
:alt="platform.label"
class="size-9 rounded-lg"
loading="lazy"
/>
</div>
<div class="flex-1">
<span
class="block text-sm font-semibold text-foreground"
>
<template v-if="platform.label.includes('(')">
{{ platform.label.split('(')[0].trim() }}
</template>
<template v-else>{{ platform.label }}</template>
</span>
<p
class="mt-0.5 line-clamp-2 text-xs leading-tight text-foreground/60"
>
{{ getPlatformDescription(platform.value) }}
</p>
</div>
<Button
size="sm"
class="mt-auto w-full"
@click="connectPlatform(platform.value)"
>
{{ $t('accounts.connect_cta') }}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<TelegramConnectDialog v-model:open="telegramOpen" />
</div>
</template>

View file

@ -0,0 +1,356 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { IconAlertTriangle, IconCheck, IconPlus } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import TelegramConnectDialog from '@/components/accounts/TelegramConnectDialog.vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import { Button } from '@/components/ui/button';
import { useOAuthPopup } from '@/composables/useOAuthPopup';
import { disconnect } from '@/routes/app/accounts';
import { Platform } from '@/types/platform';
export interface AvailablePlatform {
value: string;
label: string;
color: string;
network: string;
}
export interface ConnectedAccount {
id: string;
platform: string;
username: string;
display_name: string;
avatar_url: string | null;
status: 'connected' | 'disconnected' | 'token_expired' | null;
}
const props = withDefaults(
defineProps<{
platforms: AvailablePlatform[];
connectedAccounts?: ConnectedAccount[];
onboarding?: boolean;
gridClass?: string;
}>(),
{
connectedAccounts: () => [],
onboarding: false,
gridClass: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-5',
},
);
const emit = defineEmits<{ connect: [] }>();
const getPlatformDescription = (platform: string): string =>
trans(`accounts.descriptions.${platform}`);
// Mirrors `NetworksGrid.vue` from the marketing site pastel tile bg
// + ink 2px border + slight rotation per platform, real PNG logo inside.
// `linkedin-page` / `instagram-facebook` fall back to the base brand
// image and same color since they're variants of the same network.
const platformTheme: Record<
string,
{ bg: string; rotate: string; image: string }
> = {
instagram: {
bg: 'bg-pink-200',
rotate: '-rotate-2',
image: '/images/accounts/instagram.png',
},
'instagram-facebook': {
bg: 'bg-pink-200',
rotate: '-rotate-2',
image: '/images/accounts/instagram.png',
},
facebook: {
bg: 'bg-sky-200',
rotate: 'rotate-1',
image: '/images/accounts/facebook.png',
},
linkedin: {
bg: 'bg-blue-200',
rotate: '-rotate-1',
image: '/images/accounts/linkedin.png',
},
'linkedin-page': {
bg: 'bg-blue-200',
rotate: '-rotate-1',
image: '/images/accounts/linkedin.png',
},
x: {
bg: 'bg-amber-200',
rotate: 'rotate-2',
image: '/images/accounts/x.png',
},
tiktok: {
bg: 'bg-fuchsia-200',
rotate: '-rotate-1',
image: '/images/accounts/tiktok.png',
},
youtube: {
bg: 'bg-red-200',
rotate: 'rotate-1',
image: '/images/accounts/youtube.png',
},
pinterest: {
bg: 'bg-rose-200',
rotate: '-rotate-2',
image: '/images/accounts/pinterest.png',
},
threads: {
bg: 'bg-emerald-200',
rotate: 'rotate-2',
image: '/images/accounts/threads.png',
},
bluesky: {
bg: 'bg-cyan-200',
rotate: '-rotate-1',
image: '/images/accounts/bluesky.png',
},
mastodon: {
bg: 'bg-violet-200',
rotate: 'rotate-1',
image: '/images/accounts/mastodon.png',
},
telegram: {
bg: 'bg-sky-200',
rotate: '-rotate-2',
image: '/images/accounts/telegram.png',
},
discord: {
bg: 'bg-indigo-200',
rotate: 'rotate-1',
image: '/images/accounts/discord.png',
},
};
const themeFor = (value: string) =>
platformTheme[value] ?? { bg: 'bg-muted', rotate: '', image: '' };
const networkOf = (value: string): string =>
props.platforms.find((platform) => platform.value === value)?.network ??
value;
// One account per network: map each connected network to its account so every
// platform card belonging to that network reflects the connection.
const connectedByNetwork = computed((): Record<string, ConnectedAccount> => {
const map: Record<string, ConnectedAccount> = {};
for (const account of props.connectedAccounts) {
const network = networkOf(account.platform);
if (!map[network]) {
map[network] = account;
}
}
return map;
});
// platform value -> the account occupying its network (if any).
const cardConnection = computed(
(): Record<string, ConnectedAccount | undefined> => {
const map: Record<string, ConnectedAccount | undefined> = {};
for (const platform of props.platforms) {
map[platform.value] = connectedByNetwork.value[platform.network];
}
return map;
},
);
const telegramOpen = ref(false);
const disconnectModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(
null,
);
const { openOAuthPopup } = useOAuthPopup(() => {
router.reload();
});
const disconnectAccount = (account: ConnectedAccount) => {
disconnectModal.value?.open({
url: disconnect.url(account.id),
confirmText: account.username || account.display_name,
});
};
const needsReconnect = (account: ConnectedAccount): boolean =>
account.status === 'disconnected' || account.status === 'token_expired';
const openConnect = (platformValue: string) => {
emit('connect');
if (platformValue === Platform.Telegram) {
telegramOpen.value = true;
return;
}
openOAuthPopup(
platformValue,
props.onboarding ? { onboarding: '1' } : undefined,
);
};
const connectPlatform = (platformValue: string) => {
if (cardConnection.value[platformValue]) {
return;
}
openConnect(platformValue);
};
const reconnectAccount = (account: ConnectedAccount) => {
openConnect(account.platform);
};
const CardState = {
Connect: 'connect',
Connected: 'connected',
Reconnect: 'reconnect',
} as const;
type CardStateValue = (typeof CardState)[keyof typeof CardState];
const cardState = computed((): Record<string, CardStateValue> => {
const map: Record<string, CardStateValue> = {};
for (const platform of props.platforms) {
const account = connectedByNetwork.value[platform.network];
map[platform.value] = !account
? CardState.Connect
: needsReconnect(account)
? CardState.Reconnect
: CardState.Connected;
}
return map;
});
</script>
<template>
<div>
<div :class="['grid gap-4', gridClass]">
<div
v-for="platform in platforms"
:key="platform.value"
:class="[
'group relative flex flex-col items-center gap-3 rounded-xl border-2 border-foreground p-4 text-center shadow-xs transition-shadow',
cardState[platform.value] === CardState.Connected
? 'bg-emerald-50'
: cardState[platform.value] === CardState.Reconnect
? 'bg-amber-50'
: 'bg-card hover:shadow-md',
]"
>
<span
v-if="cardState[platform.value] === CardState.Connected"
class="absolute -top-2 -right-2 inline-flex size-6 items-center justify-center rounded-full border-2 border-foreground bg-emerald-200 text-emerald-700 shadow-2xs"
aria-hidden="true"
>
<IconCheck class="size-3.5" stroke-width="3" />
</span>
<span
v-else-if="cardState[platform.value] === CardState.Reconnect"
class="absolute -top-2 -right-2 inline-flex size-6 items-center justify-center rounded-full border-2 border-foreground bg-amber-200 text-amber-700 shadow-2xs"
aria-hidden="true"
>
<IconAlertTriangle class="size-3.5" stroke-width="2.5" />
</span>
<span
v-else
class="pointer-events-none absolute -top-2 -right-2 inline-flex size-6 items-center justify-center rounded-full border-2 border-foreground bg-violet-200 text-foreground opacity-0 shadow-2xs transition-all group-hover:scale-110 group-hover:rotate-90 group-hover:opacity-100"
aria-hidden="true"
>
<IconPlus class="size-3.5" stroke-width="3" />
</span>
<div
:class="[
themeFor(platform.value).bg,
themeFor(platform.value).rotate,
'inline-flex size-16 items-center justify-center rounded-2xl border-2 border-foreground shadow-sm transition-transform group-hover:!rotate-0',
]"
>
<img
:src="themeFor(platform.value).image"
:alt="platform.label"
class="size-9 rounded-lg"
loading="lazy"
/>
</div>
<div class="w-full min-w-0 flex-1">
<span
class="block truncate text-sm font-semibold text-foreground"
>
<template v-if="platform.label.includes('(')">
{{ platform.label.split('(')[0].trim() }}
</template>
<template v-else>{{ platform.label }}</template>
</span>
<p
v-if="cardState[platform.value] === CardState.Connect"
class="mt-0.5 line-clamp-2 text-xs leading-tight text-foreground/60"
>
{{ getPlatformDescription(platform.value) }}
</p>
<p
v-else-if="cardState[platform.value] === CardState.Reconnect"
class="mt-0.5 truncate text-xs leading-tight font-medium text-amber-700"
>
{{ $t('accounts.connection_lost') }}
</p>
<p
v-else
class="mt-0.5 truncate text-xs leading-tight text-foreground/70"
>
{{
cardConnection[platform.value]?.display_name ||
cardConnection[platform.value]?.username
}}
</p>
</div>
<Button
v-if="cardState[platform.value] === CardState.Reconnect"
size="sm"
class="mt-auto w-full"
@click="reconnectAccount(cardConnection[platform.value]!)"
>
{{ $t('accounts.reconnect') }}
</Button>
<Button
v-else-if="cardState[platform.value] === CardState.Connected"
variant="destructive"
size="sm"
class="mt-auto w-full"
@click="disconnectAccount(cardConnection[platform.value]!)"
>
{{ $t('accounts.disconnect') }}
</Button>
<Button
v-else
size="sm"
class="mt-auto w-full"
@click="connectPlatform(platform.value)"
>
{{ $t('accounts.connect_cta') }}
</Button>
</div>
</div>
<TelegramConnectDialog v-model:open="telegramOpen" />
<ConfirmDeleteModal
ref="disconnectModal"
:title="$t('accounts.disconnect_modal.title')"
:description="$t('accounts.disconnect_modal.description')"
:action="$t('accounts.disconnect_modal.confirm')"
:cancel="$t('accounts.disconnect_modal.cancel')"
/>
</div>
</template>

View file

@ -9,12 +9,20 @@ const POPUP_HEIGHT = 700;
* message. The listener is wired to the calling component's lifecycle.
*/
export const useOAuthPopup = (onSuccess: () => void) => {
const openOAuthPopup = (platform: string) => {
const openOAuthPopup = (
platform: string,
query?: Record<string, string>,
) => {
const left = window.screenX + (window.outerWidth - POPUP_WIDTH) / 2;
const top = window.screenY + (window.outerHeight - POPUP_HEIGHT) / 2;
const search =
query && Object.keys(query).length > 0
? `?${new URLSearchParams(query).toString()}`
: '';
window.open(
`/connect/${platform}`,
`/connect/${platform}${search}`,
'oauth-popup',
`width=${POPUP_WIDTH},height=${POPUP_HEIGHT},left=${left},top=${top},scrollbars=yes,resizable=yes`,
);

View file

@ -1,119 +1,17 @@
<script setup lang="ts">
import { Head, InfiniteScroll, router } from '@inertiajs/vue3';
import {
IconAffiliate,
IconAlertCircle,
IconDots,
IconExternalLink,
IconRefresh,
IconSearch,
IconTrash,
} from '@tabler/icons-vue';
import { computed, ref, watch } from 'vue';
import { Head } from '@inertiajs/vue3';
import { index as accountsIndex } from '@/actions/App/Http/Controllers/Auth/SocialController';
import AddSocialDialog, { type AvailablePlatform } from '@/components/accounts/AddSocialDialog.vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import EmptyState from '@/components/EmptyState.vue';
import NetworkConnectGrid, {
type AvailablePlatform,
type ConnectedAccount,
} from '@/components/accounts/NetworkConnectGrid.vue';
import PageHeader from '@/components/PageHeader.vue';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableLoadMore,
TableRow,
} from '@/components/ui/table';
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
import date from '@/date';
import debounce from '@/debounce';
import AppLayout from '@/layouts/AppLayout.vue';
import { disconnect as disconnectAccount, toggle as toggleAccount } from '@/routes/app/accounts';
interface SocialAccount {
id: string;
platform: string;
platform_user_id: string;
username: string;
display_name: string;
avatar_url: string;
profile_url: string | null;
status: 'connected' | 'disconnected' | 'token_expired' | null;
is_active: boolean;
error_message: string | null;
last_used_at: string | null;
created_at: string;
}
interface ScrollAccounts {
data: SocialAccount[];
meta: { hasNextPage: boolean };
}
interface Props {
accounts: ScrollAccounts;
defineProps<{
platforms: AvailablePlatform[];
filters: { search: string };
openDialog: boolean;
}
const props = defineProps<Props>();
const isAddDialogOpen = ref(props.openDialog);
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const searchQuery = ref(props.filters.search);
const handleAddClick = () => {
isAddDialogOpen.value = true;
};
const search = debounce(() => {
router.get(
accountsIndex.url(),
{ search: searchQuery.value || undefined },
{ preserveState: true, preserveScroll: true, reset: ['accounts'] },
);
}, 300);
watch(searchQuery, () => search());
const hasActiveSearch = computed(() => Boolean(searchQuery.value?.trim()));
const isDisconnected = (account: SocialAccount): boolean =>
account.status === 'disconnected' || account.status === 'token_expired';
const openOAuthPopup = (platformValue: string) => {
const url = `/connect/${platformValue}`;
const w = 600;
const h = 700;
const left = window.screenX + (window.outerWidth - w) / 2;
const top = window.screenY + (window.outerHeight - h) / 2;
window.open(url, 'oauth-popup', `width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`);
};
const handleToggle = (accountId: string) => {
router.put(toggleAccount.url(accountId), {}, { preserveScroll: true });
};
const handleDisconnect = (account: SocialAccount) => {
deleteModal.value?.open({
url: disconnectAccount.url(account.id),
confirmText: account.username || account.display_name,
});
};
connectedAccounts: ConnectedAccount[];
}>();
</script>
<template>
@ -121,162 +19,15 @@ const handleDisconnect = (account: SocialAccount) => {
<AppLayout>
<div class="flex h-full flex-1 flex-col gap-6 px-6 py-8">
<PageHeader :title="$t('accounts.page_title')" />
<div class="flex items-center justify-between gap-3">
<div class="relative">
<IconSearch class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
v-model="searchQuery"
:placeholder="$t('accounts.search')"
class="w-64 pl-9"
/>
</div>
<Button @click="handleAddClick">
{{ $t('accounts.add_social') }}
</Button>
</div>
<EmptyState
v-if="accounts.data.length === 0"
:icon="IconAffiliate"
:title="hasActiveSearch ? $t('accounts.no_search_results') : $t('accounts.no_accounts')"
:description="hasActiveSearch ? $t('accounts.try_different_search') : $t('accounts.no_accounts_description')"
<PageHeader
:title="$t('accounts.page_title')"
:description="$t('accounts.description')"
/>
<div v-else>
<InfiniteScroll data="accounts" items-element="#accounts-body" preserve-url>
<Table>
<TableHeader>
<TableRow>
<TableHead>{{ $t('accounts.table.account') }}</TableHead>
<TableHead>{{ $t('accounts.table.platform') }}</TableHead>
<TableHead>{{ $t('accounts.table.status') }}</TableHead>
<TableHead>{{ $t('accounts.table.last_used') }}</TableHead>
<TableHead>{{ $t('accounts.table.added') }}</TableHead>
<TableHead class="text-right">{{ $t('accounts.table.active') }}</TableHead>
<TableHead class="w-10" />
</TableRow>
</TableHeader>
<TableBody id="accounts-body">
<TableRow v-for="account in accounts.data" :key="account.id">
<TableCell>
<div class="flex items-center gap-3">
<div class="relative shrink-0">
<Avatar class="size-10 rounded-full border-2 border-foreground shadow-2xs">
<AvatarImage v-if="account.avatar_url" :src="account.avatar_url" />
<AvatarFallback class="rounded-full bg-violet-100 font-bold text-foreground">
{{ account.display_name?.charAt(0) }}
</AvatarFallback>
</Avatar>
<span
class="absolute -bottom-1 -right-1 inline-flex size-5 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs"
>
<img
:src="getPlatformLogo(account.platform)"
:alt="account.platform"
class="size-full object-cover"
/>
</span>
</div>
<div class="min-w-0">
<a
v-if="account.profile_url"
:href="account.profile_url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 truncate text-sm font-bold text-foreground hover:underline"
>
{{ account.display_name }}
<IconExternalLink class="size-3.5 opacity-60" />
</a>
<p v-else class="truncate text-sm font-bold text-foreground">
{{ account.display_name }}
</p>
<p class="truncate text-xs font-medium text-foreground/60">
@{{ account.username || account.display_name }}
</p>
</div>
</div>
</TableCell>
<TableCell>
{{ getPlatformLabel(account.platform) }}
</TableCell>
<TableCell>
<Badge v-if="!isDisconnected(account)" variant="success">
{{ $t('accounts.status.connected') }}
</Badge>
<Badge v-else variant="destructive">
<IconAlertCircle class="size-3" />
{{ $t('accounts.status.disconnected') }}
</Badge>
</TableCell>
<TableCell>
<span v-if="account.last_used_at">{{ date.diffForHumans(account.last_used_at) }}</span>
<span v-else>{{ $t('accounts.never_used') }}</span>
</TableCell>
<TableCell>
{{ date.diffForHumans(account.created_at) }}
</TableCell>
<TableCell class="text-right">
<Switch
:model-value="account.is_active"
:disabled="isDisconnected(account)"
@update:model-value="handleToggle(account.id)"
/>
</TableCell>
<TableCell class="text-right">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="size-8">
<IconDots class="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
v-if="isDisconnected(account)"
@click="openOAuthPopup(account.platform)"
>
<IconRefresh class="size-4" />
{{ $t('accounts.reconnect_account') }}
</DropdownMenuItem>
<DropdownMenuSeparator v-if="isDisconnected(account)" />
<DropdownMenuItem
variant="destructive"
@click="handleDisconnect(account)"
>
<IconTrash class="size-4" />
{{ $t('accounts.disconnect') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
</TableBody>
</Table>
<template #next="{ loading }">
<TableLoadMore v-if="loading" />
</template>
</InfiniteScroll>
</div>
<NetworkConnectGrid
:platforms="platforms"
:connected-accounts="connectedAccounts"
/>
</div>
</AppLayout>
<AddSocialDialog v-model:open="isAddDialogOpen" :platforms="platforms" />
<ConfirmDeleteModal
ref="deleteModal"
:title="$t('accounts.disconnect_modal.title')"
:description="$t('accounts.disconnect_modal.description')"
:action="$t('accounts.disconnect_modal.confirm')"
:cancel="$t('accounts.disconnect_modal.cancel')"
/>
</template>

View file

@ -0,0 +1,78 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import { IconArrowRight } from '@tabler/icons-vue';
import { computed } from 'vue';
import { checkout } from '@/actions/App/Http/Controllers/App/OnboardingController';
import NetworkConnectGrid, {
type AvailablePlatform,
type ConnectedAccount,
} from '@/components/accounts/NetworkConnectGrid.vue';
import { Button } from '@/components/ui/button';
const props = defineProps<{
platforms: AvailablePlatform[];
accounts: ConnectedAccount[];
}>();
const form = useForm({});
const hasConnected = computed((): boolean => props.accounts.length > 0);
const submit = (): void => {
if (!hasConnected.value || form.processing) {
return;
}
form.post(checkout.url());
};
</script>
<template>
<Head :title="$t('onboarding.connect.title')" />
<section class="relative min-h-screen overflow-hidden bg-background">
<div
class="pointer-events-none absolute inset-0 opacity-[0.06]"
style="background-image: radial-gradient(circle, #0a0a0a 1px, transparent 1px); background-size: 28px 28px;"
/>
<div class="pointer-events-none absolute -top-20 right-0 size-[560px] rounded-full bg-violet-200/50 blur-3xl" />
<div class="relative mx-auto flex min-h-screen max-w-7xl flex-col justify-center px-6 py-12">
<div class="mx-auto mb-10 max-w-xl space-y-3 text-center">
<h1
class="text-balance text-3xl font-normal leading-[1.1] tracking-tight text-foreground sm:text-4xl"
style="font-family: var(--font-display);"
>
{{ $t('onboarding.connect.title') }}
</h1>
<p class="text-balance text-base text-muted-foreground">
{{ $t('onboarding.connect.description') }}
</p>
</div>
<NetworkConnectGrid
:platforms="platforms"
:connected-accounts="accounts"
onboarding
grid-class="grid-cols-3 sm:grid-cols-4 lg:grid-cols-7"
/>
<div class="mx-auto mt-10 flex w-full max-w-sm flex-col items-center gap-3">
<Button
type="button"
size="lg"
class="w-full rounded-full"
:disabled="!hasConnected || form.processing"
@click="submit"
>
{{ $t('onboarding.continue') }}
<IconArrowRight class="size-4" />
</Button>
<p v-if="!hasConnected" class="text-center text-xs text-foreground/60">
{{ $t('onboarding.connect.must_connect') }}
</p>
</div>
</div>
</section>
</template>

View file

@ -13,6 +13,7 @@ import {
import { trans } from 'laravel-vue-i18n';
import type { FunctionalComponent } from 'vue';
import { Button } from '@/components/ui/button';
import { store } from '@/routes/app/onboarding';
const props = defineProps<{
@ -22,15 +23,19 @@ const props = defineProps<{
const form = useForm({ persona: props.selected ?? '' });
const icons: Record<string, FunctionalComponent> = {
creator: IconUser,
freelancer: IconBriefcase,
startup: IconRocket,
agency: IconBuildingSkyscraper,
small_business: IconBuildingStore,
other: IconDots,
const personaMeta: Record<string, { icon: FunctionalComponent; color: string }> = {
creator: { icon: IconUser, color: 'text-rose-600' },
freelancer: { icon: IconBriefcase, color: 'text-amber-600' },
startup: { icon: IconRocket, color: 'text-violet-700' },
agency: { icon: IconBuildingSkyscraper, color: 'text-blue-700' },
small_business: { icon: IconBuildingStore, color: 'text-emerald-600' },
other: { icon: IconDots, color: 'text-sky-600' },
};
const personaIcon = (value: string): FunctionalComponent => personaMeta[value]?.icon ?? IconDots;
const personaColor = (value: string): string => personaMeta[value]?.color ?? 'text-foreground';
const personaLabel = (value: string): string => trans(`onboarding.personas.${value}`);
const select = (value: string): void => {
@ -81,7 +86,11 @@ const submit = (): void => {
@click="select(persona)"
>
<span class="inline-flex size-10 items-center justify-center rounded-2xl border-2 border-foreground bg-card shadow-2xs">
<component :is="icons[persona] ?? IconDots" class="size-5 text-foreground" stroke-width="2" />
<component
:is="personaIcon(persona)"
:class="[personaColor(persona), 'size-5']"
stroke-width="2.25"
/>
</span>
<span class="text-base font-bold tracking-tight text-foreground">
{{ personaLabel(persona) }}
@ -96,18 +105,16 @@ const submit = (): void => {
</div>
<div class="mx-auto mt-10 flex w-full max-w-sm flex-col items-center gap-3">
<button
<Button
type="button"
size="lg"
class="w-full rounded-full"
:disabled="!form.persona || form.processing"
class="inline-flex w-full cursor-pointer items-center justify-center gap-1.5 rounded-full border-2 border-foreground bg-foreground px-4 py-3 text-sm font-semibold text-background shadow-2xs transition-shadow hover:shadow-xs disabled:cursor-not-allowed disabled:opacity-60"
@click="submit"
>
{{ $t('onboarding.continue') }}
<IconArrowRight class="size-4" />
</button>
<p class="text-center text-xs text-foreground/60">
{{ $t('onboarding.trial_note') }}
</p>
</Button>
</div>
</div>
</section>

View file

@ -58,6 +58,8 @@
Route::get('subscribe', [BillingController::class, 'subscribe'])->name('app.subscribe');
Route::get('onboarding', [OnboardingController::class, 'index'])->name('app.onboarding');
Route::post('onboarding', [OnboardingController::class, 'store'])->name('app.onboarding.store');
Route::get('onboarding/connect', [OnboardingController::class, 'connect'])->name('app.onboarding.connect');
Route::post('onboarding/connect', [OnboardingController::class, 'checkout'])->name('app.onboarding.checkout');
Route::get('billing/processing', [BillingController::class, 'processing'])->name('app.billing.processing');
Route::get('workspaces/create', [WorkspaceController::class, 'create'])->name('app.workspaces.create');
@ -127,6 +129,11 @@
Route::get('connect/discord', [DiscordController::class, 'connect'])->name('app.social.discord.connect');
Route::get('accounts/discord/callback', [DiscordController::class, 'callback'])->name('app.social.discord.callback');
// Disconnecting must also work during onboarding (before a subscription
// exists), so it lives here rather than behind EnsureAccountReady — the
// controller still authorizes workspace ownership.
Route::delete('accounts/{account}', [SocialController::class, 'disconnect'])->name('app.accounts.disconnect');
});
// Routes that require active subscription and completed onboarding
@ -156,7 +163,6 @@
// Social Accounts
Route::get('accounts', [SocialController::class, 'index'])->name('app.accounts');
Route::delete('accounts/{account}', [SocialController::class, 'disconnect'])->name('app.accounts.disconnect');
Route::put('accounts/{account}/toggle', [SocialController::class, 'toggleActive'])->name('app.accounts.toggle');
// Analytics

View file

@ -8,7 +8,9 @@
use App\Jobs\PostHog\SendEvent;
use App\Models\Account;
use App\Models\Plan;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Bus;
beforeEach(function () {
@ -16,6 +18,31 @@
$this->user = User::factory()->create();
});
/**
* Give the acting user a current workspace under their account.
*/
function onboardingWorkspace(User $user): Workspace
{
$workspace = Workspace::factory()->create([
'user_id' => $user->id,
'account_id' => $user->account_id,
]);
$user->update(['current_workspace_id' => $workspace->id]);
return $workspace;
}
function subscribeOnboardingAccount(Account $account): void
{
$account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_'.fake()->uuid(),
'stripe_status' => 'active',
'stripe_price' => 'price_123',
]);
}
test('onboarding renders the persona selection for an unsubscribed account', function () {
$response = $this->actingAs($this->user)->get(route('app.onboarding'));
@ -35,12 +62,7 @@
});
test('onboarding redirects to calendar when already subscribed', function () {
$this->user->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_'.fake()->uuid(),
'stripe_status' => 'active',
'stripe_price' => 'price_123',
]);
subscribeOnboardingAccount($this->user->account);
$response = $this->actingAs($this->user->fresh())->get(route('app.onboarding'));
@ -73,10 +95,102 @@
expect($this->user->fresh()->persona)->toBeNull();
});
test('onboarding store saves the persona, mirrors to PostHog and starts monthly checkout', function () {
test('onboarding store saves the persona, mirrors to PostHog and advances to the connect step', function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']);
Bus::fake();
$response = $this->actingAs($this->user)->post(route('app.onboarding.store'), [
'persona' => Persona::Agency->value,
]);
$response->assertRedirect(route('app.onboarding.connect'));
expect($this->user->fresh()->persona)->toBe(Persona::Agency);
Bus::assertDispatched(SendEvent::class);
});
test('onboarding store redirects an already-subscribed account to the calendar', function () {
subscribeOnboardingAccount($this->user->account);
$response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.store'), [
'persona' => Persona::Agency->value,
]);
$response->assertRedirect(route('app.calendar'));
expect($this->user->fresh()->persona)->toBeNull();
});
test('connect renders the network grid for an unsubscribed account that picked a persona', function () {
$this->user->update(['persona' => Persona::Agency->value]);
onboardingWorkspace($this->user);
$response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('onboarding/Connect')
->has('platforms')
->has('platforms.0.network')
->has('accounts')
);
});
test('connect lists the workspace social accounts already connected', function () {
$this->user->update(['persona' => Persona::Agency->value]);
$workspace = onboardingWorkspace($this->user);
SocialAccount::factory()->create(['workspace_id' => $workspace->id]);
$response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('onboarding/Connect')
->has('accounts', 1)
);
});
test('connect redirects back to the persona step when no persona was chosen', function () {
onboardingWorkspace($this->user);
$response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect'));
$response->assertRedirect(route('app.onboarding'));
});
test('connect redirects to calendar in self-hosted mode', function () {
config(['trypost.self_hosted' => true]);
$response = $this->actingAs($this->user)->get(route('app.onboarding.connect'));
$response->assertRedirect(route('app.calendar'));
});
test('connect redirects to calendar when already subscribed', function () {
subscribeOnboardingAccount($this->user->account);
$response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect'));
$response->assertRedirect(route('app.calendar'));
});
test('checkout blocks and redirects back when no network is connected', function () {
$this->user->update(['persona' => Persona::Agency->value]);
onboardingWorkspace($this->user);
$this->mock(StartSubscriptionCheckout::class)
->shouldReceive('redirect')
->never();
$response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.checkout'));
$response->assertRedirect(route('app.onboarding.connect'));
});
test('checkout starts monthly checkout once at least one network is connected', function () {
$this->user->update(['persona' => Persona::Agency->value]);
$workspace = onboardingWorkspace($this->user);
SocialAccount::factory()->create(['workspace_id' => $workspace->id]);
Plan::where('slug', Slug::Workspace)->firstOrFail()->update([
'stripe_monthly_price_id' => 'price_monthly_test',
'stripe_yearly_price_id' => 'price_yearly_test',
@ -88,32 +202,31 @@
->withArgs(fn (Account $account, string $priceId, string $cancelUrl): bool => $priceId === 'price_monthly_test')
->andReturn(redirect()->route('app.calendar'));
$response = $this->actingAs($this->user)->post(route('app.onboarding.store'), [
'persona' => Persona::Agency->value,
]);
$response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.checkout'));
$response->assertRedirect(route('app.calendar'));
expect($this->user->fresh()->persona)->toBe(Persona::Agency);
Bus::assertDispatched(SendEvent::class);
});
test('onboarding store redirects an already-subscribed account to the calendar without starting a second checkout', function () {
$this->user->account->subscriptions()->create([
'type' => Account::SUBSCRIPTION_NAME,
'stripe_id' => 'sub_'.fake()->uuid(),
'stripe_status' => 'active',
'stripe_price' => 'price_123',
]);
test('checkout redirects an already-subscribed account to the calendar', function () {
subscribeOnboardingAccount($this->user->account);
$this->mock(StartSubscriptionCheckout::class)
->shouldReceive('redirect')
->never();
$response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.store'), [
'persona' => Persona::Agency->value,
]);
$response = $this->actingAs($this->user->fresh())->post(route('app.onboarding.checkout'));
$response->assertRedirect(route('app.calendar'));
});
test('checkout does nothing in self-hosted mode', function () {
config(['trypost.self_hosted' => true]);
$this->mock(StartSubscriptionCheckout::class)
->shouldReceive('redirect')
->never();
$response = $this->actingAs($this->user)->post(route('app.onboarding.checkout'));
$response->assertRedirect(route('app.calendar'));
expect($this->user->fresh()->persona)->toBeNull();
});

View file

@ -38,9 +38,23 @@
->component('accounts/Index', false)
->has('workspace')
->has('platforms')
->has('platforms.0.network')
->has('connectedAccounts', 1)
);
});
test('an unsubscribed account can disconnect during onboarding (no active subscription required)', function () {
config(['trypost.self_hosted' => false]);
$account = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]);
$response = $this->actingAs($this->user)->delete(route('app.accounts.disconnect', $account));
$response->assertRedirect();
$response->assertSessionMissing('errors');
expect(SocialAccount::find($account->id))->toBeNull();
});
test('accounts index redirects if no workspace', function () {
$this->user->update(['current_workspace_id' => null]);

View file

@ -143,6 +143,6 @@
'name' => 'Second workspace',
]);
$response->assertRedirect(route('app.accounts', ['openDialog' => 'true']));
$response->assertRedirect(route('app.accounts'));
expect($this->account->workspaces()->count())->toBe(2);
});

View file

@ -89,7 +89,7 @@
'name' => 'New Workspace',
]);
$response->assertRedirect(route('app.accounts', ['openDialog' => 'true']));
$response->assertRedirect(route('app.accounts'));
$this->assertDatabaseHas('workspaces', [
'name' => 'New Workspace',
@ -104,7 +104,7 @@
'name' => 'Second Workspace',
]);
$response->assertRedirect(route('app.accounts', ['openDialog' => 'true']));
$response->assertRedirect(route('app.accounts'));
$this->assertDatabaseHas('workspaces', [
'name' => 'Second Workspace',
@ -566,7 +566,7 @@
});
// Brand-aware store tests
test('store persists brand fields and redirects to /accounts with openDialog flag', function () {
test('store persists brand fields and redirects to /accounts', function () {
$account = Account::factory()->create();
$user = User::factory()->create(['account_id' => $account->id]);
$account->update(['owner_id' => $user->id]);
@ -586,7 +586,7 @@
'content_language' => 'en',
]);
$response->assertRedirect(route('app.accounts', ['openDialog' => 'true']));
$response->assertRedirect(route('app.accounts'));
$workspace = Workspace::where('name', 'Acme Inc')->sole();
expect($workspace->name)->toBe('Acme Inc');
@ -594,7 +594,7 @@
expect($workspace->brand_description)->toBe('We sell rockets.');
});
test('store redirects additional workspace to /accounts with openDialog flag', function () {
test('store redirects additional workspace to /accounts', function () {
$account = Account::factory()->create();
$user = User::factory()->create(['account_id' => $account->id]);
$account->update(['owner_id' => $user->id]);
@ -614,7 +614,7 @@
'name' => 'Second Workspace',
]);
$response->assertRedirect(route('app.accounts', ['openDialog' => 'true']));
$response->assertRedirect(route('app.accounts'));
expect(Workspace::where('account_id', $account->id)->count())->toBe(2);
});