trypost/resources/js/layouts/auth/AuthSplitLayout.vue
Paulo Castellano f3f72afecd refactor: auth split layout, subscribe redesign, onboarding, i18n, cookie locale
Auth pages:
- Create AuthSplitLayout with animated feature slides (6 slides, 3 languages)
- All auth pages use split layout (form left, visual right)
- Add show/hide password toggle with tooltip on Register
- Legal footer only shown on Register via showLegal prop

Subscribe page:
- Redesign to match auth card pattern (centered, clean)
- Platform icons, feature checklist, dynamic trial days (trialDays - 1)
- Add "Switch workspace" link
- Full i18n (en, es, pt-BR)

Onboarding:
- Rename URLs: step1 -> role, step2 -> connect
- Add enforceStep() to prevent skipping/going back steps
- Redirect /onboarding to /onboarding/role
- Redesign Step2 with AuthSplitLayout and compact platform list
- 21 tests covering all step enforcement scenarios

Workspaces page:
- Redesign with AuthSplitLayout (list with avatars, current badge)

Language system:
- Move locale from DB to cookie (forever, unencrypted, session.domain)
- Create SetLocale middleware (sets cookie if missing, validates against config)
- Rename lang/pt-br to lang/pt-BR
- Add dayjs es locale

Other:
- Copy utils.ts from sendkit (formatNumber, formatMoney, copyToClipboard)
- ConfirmDeleteModal with text confirmation (sendkit pattern)
- i18n for ConfirmDeleteModal internal strings (common.php)
- EmptyState component for posts index
- Exact match for "All" posts in sidebar
- Posts breadcrumbs show current status filter
- DialogFooter buttons aligned left
- API Keys page redesign with Table, DropdownMenu, EmptyState
- Extract CreateApiKeyDialog and InviteMemberDialog to components
- Remove API Keys from sidebar
- DropdownMenuItem destructive variant for Remove action
2026-03-30 11:53:42 -03:00

226 lines
9.3 KiB
Vue

<script setup lang="ts">
import {
IconCalendar,
IconClock,
IconHash,
IconPhoto,
IconUsers,
IconVideo,
} from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { onBeforeUnmount, onMounted, ref, computed } from 'vue';
withDefaults(defineProps<{
title?: string;
description?: string;
showLegal?: boolean;
}>(), {
showLegal: false,
});
const slideKeys = ['calendar', 'scheduling', 'media', 'video', 'team', 'hashtags'] as const;
const slideIcons = {
calendar: IconCalendar,
scheduling: IconClock,
media: IconPhoto,
video: IconVideo,
team: IconUsers,
hashtags: IconHash,
};
const slides = computed(() =>
slideKeys.map((key) => ({
icon: slideIcons[key],
title: trans(`auth.slides.${key}.title`),
description: trans(`auth.slides.${key}.description`),
})),
);
const activeIndex = ref(0);
const isPaused = ref(false);
let intervalId: ReturnType<typeof setInterval> | null = null;
const activeSlide = computed(() => slides.value[activeIndex.value]);
const goTo = (index: number) => {
activeIndex.value = index;
restartInterval();
};
const startInterval = () => {
intervalId = setInterval(() => {
if (!isPaused.value) {
activeIndex.value = (activeIndex.value + 1) % slides.value.length;
}
}, 4000);
};
const restartInterval = () => {
if (intervalId) {
clearInterval(intervalId);
}
startInterval();
};
onMounted(() => {
startInterval();
});
onBeforeUnmount(() => {
if (intervalId) {
clearInterval(intervalId);
}
});
const platforms = [
{ name: 'LinkedIn', icon: '/images/accounts/linkedin.png' },
{ name: 'X', icon: '/images/accounts/x.png' },
{ name: 'Instagram', icon: '/images/accounts/instagram.png' },
{ name: 'Facebook', icon: '/images/accounts/facebook.png' },
{ name: 'TikTok', icon: '/images/accounts/tiktok.png' },
{ name: 'YouTube', icon: '/images/accounts/youtube.png' },
{ name: 'Threads', icon: '/images/accounts/threads.png' },
{ name: 'Pinterest', icon: '/images/accounts/pinterest.png' },
{ name: 'Bluesky', icon: '/images/accounts/bluesky.png' },
{ name: 'Mastodon', icon: '/images/accounts/mastodon.png' },
];
</script>
<template>
<div class="grid min-h-svh lg:grid-cols-2">
<div class="flex flex-col gap-4 p-6 md:p-10">
<div class="flex flex-1 items-center justify-center">
<div class="w-full max-w-md">
<div class="flex flex-col gap-6">
<div class="flex flex-col items-center gap-2 text-center">
<img
src="/images/trypost/logo-light.png"
alt="TryPost"
class="mb-2 h-10 dark:hidden"
/>
<img
src="/images/trypost/logo-dark.png"
alt="TryPost"
class="mb-2 hidden h-10 dark:block"
/>
<h1 v-if="title" class="text-2xl font-bold">{{ title }}</h1>
<p v-if="description" class="text-sm text-balance text-muted-foreground">
{{ description }}
</p>
</div>
<slot />
</div>
</div>
</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div
v-if="showLegal"
class="text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 [&_a]:hover:text-primary"
v-html="trans('auth.legal')"
/>
</div>
<div
class="relative hidden overflow-hidden lg:block"
@mouseenter="isPaused = true"
@mouseleave="isPaused = false"
>
<div class="absolute inset-0 bg-gradient-to-br from-zinc-900 via-zinc-950 to-zinc-950" />
<div
class="absolute inset-0 opacity-[0.015]"
style="background-image: url(&quot;data:image/svg+xml,%3Csvg width='40' height='40' viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0h40v40H0z' fill='none'/%3E%3Cpath d='M0 0h1v40H0zM40 0v1H0V0z' fill='%23fff'/%3E%3C/svg%3E&quot;)"
/>
<div class="relative flex h-full flex-col items-center justify-center px-12 xl:px-16">
<!-- Platform logos -->
<div class="relative h-[280px] w-full max-w-md">
<template v-for="(slide, index) in slides" :key="index">
<Transition
enter-active-class="transition-all duration-500 ease-out"
leave-active-class="transition-all duration-500 ease-out"
enter-from-class="opacity-0 translate-y-4"
enter-to-class="opacity-100 translate-y-0"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-4"
>
<div
v-if="activeIndex === index"
class="absolute inset-0 flex items-center justify-center"
>
<div class="w-full rounded-xl border border-white/10 bg-white/[0.04] p-6 shadow-2xl backdrop-blur-sm">
<div class="mb-5 flex items-center gap-2">
<div class="size-2.5 rounded-full bg-red-400/80" />
<div class="size-2.5 rounded-full bg-yellow-400/80" />
<div class="size-2.5 rounded-full bg-green-400/80" />
<div class="ml-3 h-5 w-48 rounded-md bg-white/5" />
</div>
<div class="flex items-center justify-center py-8">
<div class="flex size-20 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.06]">
<component :is="slide.icon" class="size-10 text-white/80" />
</div>
</div>
<div class="flex flex-wrap justify-center gap-2 pt-2">
<img
v-for="platform in platforms"
:key="platform.name"
:src="platform.icon"
:alt="platform.name"
class="size-7 rounded-full border border-white/10 bg-white/5 p-0.5 opacity-60"
/>
</div>
</div>
</div>
</Transition>
</template>
</div>
<!-- Text content -->
<div class="w-full max-w-md text-center">
<div class="relative h-[88px]">
<TransitionGroup
enter-active-class="transition-all duration-400 ease-out"
leave-active-class="transition-all duration-300 ease-in"
enter-from-class="opacity-0 translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div :key="activeIndex" class="absolute inset-x-0 top-0">
<h3 class="text-xl font-semibold tracking-tight text-white">
{{ activeSlide.title }}
</h3>
<p class="mx-auto mt-2 max-w-sm text-sm leading-relaxed text-zinc-400">
{{ activeSlide.description }}
</p>
</div>
</TransitionGroup>
</div>
<!-- Dots -->
<div class="flex items-center justify-center gap-2">
<button
v-for="(_, index) in slides"
:key="index"
class="group relative flex h-5 cursor-pointer items-center justify-center"
@click="goTo(index)"
>
<span
class="block h-1.5 rounded-full transition-all duration-300"
:class="activeIndex === index
? 'w-6 bg-white'
: 'w-1.5 bg-zinc-600 group-hover:bg-zinc-500'
"
/>
</button>
</div>
</div>
</div>
</div>
</div>
</template>