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:
parent
2f314c9a8c
commit
09d4e9d6b1
20 changed files with 736 additions and 619 deletions
|
|
@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
356
resources/js/components/accounts/NetworkConnectGrid.vue
Normal file
356
resources/js/components/accounts/NetworkConnectGrid.vue
Normal 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>
|
||||
|
|
@ -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`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
78
resources/js/pages/onboarding/Connect.vue
Normal file
78
resources/js/pages/onboarding/Connect.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue