mirror of
https://github.com/vernu/textbee.git
synced 2026-08-03 02:52:37 -04:00
fix: tidy the community page and close mobile layout gaps
community-links was the only file in the app still wrapping a Button in a Link, four times, producing an anchor around a button: invalid markup and a nested interactive control that assistive tech announces twice. Everywhere else already uses Button asChild. Two whole Card blocks sat commented out, with an icon imported solely for that dead code. community/page.tsx was the only dashboard section that never got the mobile pass, keeping p-6 with no p-4 step and an unconditional text-3xl. It was also the only section missing from the 375px overflow guard, which is presumably how it was missed. Extracting the shared PageHeader, which the messaging, webhooks and account layouts all repeated by hand, fixes that outlier by construction. Both billing limit grids and the promo modal were locked to two columns at every width, so the meter captions had no room on a phone. window.open kept a live opener handle back to the app in four places. Browsers imply noopener for anchor targets but not for window.open. The share dialog grid moves to 3 columns then 7. Worth stating plainly: this is not an overflow fix. I expected 7 icons at grid-cols-4 to overflow at 375px and the extended guard proved they do not. It was only an awkward 4 + 3 split. The overflow guard now covers 12 routes instead of 6 and opens a dialog, since the densest layouts in the app only exist inside modals. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -87,7 +87,7 @@ export default function BlackFridayModal() {
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Benefits List */}
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2">
|
||||
{[
|
||||
"Increased SMS limits",
|
||||
"No daily limits",
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Github,
|
||||
Heart,
|
||||
MessageSquare,
|
||||
Linkedin,
|
||||
Twitter,
|
||||
@@ -18,6 +17,7 @@ import { toast } from '@/hooks/use-toast'
|
||||
import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
} from '@/components/ui/dialog'
|
||||
@@ -77,41 +77,7 @@ export default function CommunityLinks() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='grid gap-4 md:grid-cols-2 lg:grid-cols-2'>
|
||||
{/* <Card>
|
||||
<CardHeader>
|
||||
<CardTitle>One-time Donation</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className='text-sm text-muted-foreground mb-4'>
|
||||
Support us with a one-time donation of your desired amount.
|
||||
</p>
|
||||
<Link href={ExternalLinks.polar} prefetch={false} target='_blank'>
|
||||
<Button className='w-full' variant='destructive'>
|
||||
<Heart className='mr-2 h-4 w-4' />
|
||||
Donate Once
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card> */}
|
||||
|
||||
{/* <Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Support on Patreon</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className='text-sm text-muted-foreground mb-4'>
|
||||
Support the development by becoming a patron.
|
||||
</p>
|
||||
<Link href={ExternalLinks.patreon} prefetch={false} target='_blank'>
|
||||
<Button className='w-full' variant='secondary'>
|
||||
<Heart className='mr-2 h-4 w-4' />
|
||||
Become a Patron
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card> */}
|
||||
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>GitHub</CardTitle>
|
||||
@@ -120,12 +86,12 @@ export default function CommunityLinks() {
|
||||
<p className='text-sm text-muted-foreground mb-4'>
|
||||
Check out our source code and contribute to the project.
|
||||
</p>
|
||||
<Link href={ExternalLinks.github} prefetch={false} target='_blank'>
|
||||
<Button className='w-full'>
|
||||
<Button asChild className='w-full'>
|
||||
<Link href={ExternalLinks.github} prefetch={false} target='_blank'>
|
||||
<Github className='mr-2 h-4 w-4' />
|
||||
View Source
|
||||
</Button>
|
||||
</Link>
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -137,12 +103,12 @@ export default function CommunityLinks() {
|
||||
<p className='text-sm text-muted-foreground mb-4'>
|
||||
Join our community for support and updates.
|
||||
</p>
|
||||
<Link href={ExternalLinks.discord} prefetch={false} target='_blank'>
|
||||
<Button className='w-full' variant='outline'>
|
||||
<Button asChild className='w-full' variant='outline'>
|
||||
<Link href={ExternalLinks.discord} prefetch={false} target='_blank'>
|
||||
<MessageSquare className='mr-2 h-4 w-4' />
|
||||
Join Discord
|
||||
</Button>
|
||||
</Link>
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -154,12 +120,12 @@ export default function CommunityLinks() {
|
||||
<p className='text-sm text-muted-foreground mb-4'>
|
||||
Follow us on X for the latest updates and announcements.
|
||||
</p>
|
||||
<Link href={ExternalLinks.twitter} prefetch={false} target='_blank'>
|
||||
<Button className='w-full' variant='outline'>
|
||||
<Button asChild className='w-full' variant='outline'>
|
||||
<Link href={ExternalLinks.twitter} prefetch={false} target='_blank'>
|
||||
<Twitter className='mr-2 h-4 w-4' />
|
||||
Follow on X
|
||||
</Button>
|
||||
</Link>
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -171,16 +137,12 @@ export default function CommunityLinks() {
|
||||
<p className='text-sm text-muted-foreground mb-4'>
|
||||
Connect with us on LinkedIn for updates and news.
|
||||
</p>
|
||||
<Link
|
||||
href={ExternalLinks.linkedin}
|
||||
prefetch={false}
|
||||
target='_blank'
|
||||
>
|
||||
<Button className='w-full' variant='outline'>
|
||||
<Button asChild className='w-full' variant='outline'>
|
||||
<Link href={ExternalLinks.linkedin} prefetch={false} target='_blank'>
|
||||
<Linkedin className='mr-2 h-4 w-4' />
|
||||
Connect on LinkedIn
|
||||
</Button>
|
||||
</Link>
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
@@ -205,36 +167,50 @@ export default function CommunityLinks() {
|
||||
<Dialog open={socialOpen} onOpenChange={setSocialOpen}>
|
||||
<DialogContent className='sm:max-w-[600px] text-base'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='text-primary mb-2 text-2xl font-bold'>
|
||||
<DialogTitle className='text-primary mb-2 text-xl font-bold sm:text-2xl'>
|
||||
Share textbee.dev with Others
|
||||
</DialogTitle>
|
||||
<p className='text-muted-foreground'>
|
||||
{/* A bare <p> here left the dialog with no aria-describedby. */}
|
||||
<DialogDescription>
|
||||
Help us grow by sharing textbee.dev with your friends and
|
||||
colleagues!
|
||||
</p>
|
||||
colleagues.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='flex flex-col gap-6 mt-4'>
|
||||
<div className='space-y-3'>
|
||||
<h3 className='text-lg font-semibold'>Choose your platform</h3>
|
||||
<div className='grid grid-cols-4 gap-3 p-4 bg-muted/30 rounded-lg'>
|
||||
{socials.map(({ icon, name, url }) => (
|
||||
<button
|
||||
key={name}
|
||||
type='button'
|
||||
title={name}
|
||||
onClick={() => setCurrentUrl(url)}
|
||||
className={`${
|
||||
currentUrl === url
|
||||
? 'ring-2 ring-primary bg-primary/10 shadow-lg'
|
||||
: 'hover:bg-card hover:border-primary/40 hover:shadow-md'
|
||||
} p-3 rounded-xl border bg-card/80 backdrop-blur-sm transition-colors duration-150 group`}
|
||||
>
|
||||
<div className='w-10 h-10 mx-auto bg-white dark:bg-white rounded-lg p-1 shadow-sm group-hover:shadow-md transition-shadow'>
|
||||
<Image src={icon} alt={name} width={20} height={20} className='w-full h-full object-contain' />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{/* Not an overflow fix: 7 platforms at grid-cols-4 did fit at
|
||||
375px, verified against the overflow guard. It just read
|
||||
badly, a 4 + 3 split with a stranded last row. Three columns
|
||||
on the smallest screens, then one clean row of seven. */}
|
||||
<div className='grid grid-cols-3 gap-2 rounded-lg bg-muted/30 p-3 sm:grid-cols-7 sm:gap-3 sm:p-4'>
|
||||
{socials.map(({ icon, name, url }) => (
|
||||
<button
|
||||
key={name}
|
||||
type='button'
|
||||
title={`Share on ${name}`}
|
||||
// Selection was signalled by a ring alone, which is colour
|
||||
// only. aria-pressed states it outright.
|
||||
aria-pressed={currentUrl === url}
|
||||
onClick={() => setCurrentUrl(url)}
|
||||
className={`${
|
||||
currentUrl === url
|
||||
? 'ring-2 ring-primary bg-primary/10 shadow-lg'
|
||||
: 'hover:bg-card hover:border-primary/40 hover:shadow-md'
|
||||
} group rounded-xl border bg-card/80 p-2 backdrop-blur-sm transition-colors duration-150 sm:p-3`}
|
||||
>
|
||||
<div className='mx-auto h-9 w-9 rounded-lg bg-white p-1 shadow-sm transition-shadow group-hover:shadow-md dark:bg-white sm:h-10 sm:w-10'>
|
||||
<Image
|
||||
src={icon}
|
||||
alt={name}
|
||||
width={20}
|
||||
height={20}
|
||||
className='h-full w-full object-contain'
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -259,7 +235,7 @@ export default function CommunityLinks() {
|
||||
{copiedUrl === currentUrl ? 'Copied!' : 'Copy Link'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => window.open(currentUrl, '_blank')}
|
||||
onClick={() => window.open(currentUrl, '_blank', 'noopener,noreferrer')}
|
||||
variant='outline'
|
||||
className='flex-1'
|
||||
>
|
||||
|
||||
@@ -96,7 +96,7 @@ export default function StepActions({
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => window.open(Routes.downloadAndroidApp, '_blank')}
|
||||
onClick={() => window.open(Routes.downloadAndroidApp, '_blank', 'noopener,noreferrer')}
|
||||
>
|
||||
<Download className='h-4 w-4' />
|
||||
Download APK
|
||||
|
||||
@@ -341,7 +341,7 @@ export default function SubscriptionInfo() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-2.5 mb-4'>
|
||||
<div className='grid grid-cols-1 gap-2.5 mb-4 sm:grid-cols-2'>
|
||||
<div className='flex items-center space-x-2 bg-card p-2.5 rounded-md shadow-sm'>
|
||||
<Calendar className='h-3.5 w-3.5 text-primary flex-none' />
|
||||
<div>
|
||||
@@ -398,7 +398,7 @@ export default function SubscriptionInfo() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-2.5'>
|
||||
<div className='grid grid-cols-1 gap-2.5 sm:grid-cols-2'>
|
||||
{limitTiles.map((tile) => (
|
||||
<LimitTile key={tile.label} {...tile} />
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { UserIcon } from 'lucide-react'
|
||||
import RouteTabs from '@/components/shared/route-tabs'
|
||||
import PageHeader from '@/components/shared/page-header'
|
||||
|
||||
// Account is one settings experience: sections are route-based tabs (same
|
||||
// interaction grammar as messaging/webhooks), Billing first since the
|
||||
@@ -8,17 +9,11 @@ import RouteTabs from '@/components/shared/route-tabs'
|
||||
export default function AccountLayout({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<div className='flex-1 p-4 sm:p-6 md:p-8'>
|
||||
<div className='mb-4 space-y-1'>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<UserIcon className='h-6 w-6 text-primary' />
|
||||
<h2 className='text-2xl sm:text-3xl font-bold tracking-tight'>
|
||||
Account
|
||||
</h2>
|
||||
</div>
|
||||
<p className='text-muted-foreground'>
|
||||
Manage your subscription, profile and security
|
||||
</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
icon={UserIcon}
|
||||
title='Account'
|
||||
description='Manage your subscription, profile and security'
|
||||
/>
|
||||
|
||||
<RouteTabs
|
||||
className='mb-6'
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import { UsersIcon } from 'lucide-react'
|
||||
import PageHeader from '@/components/shared/page-header'
|
||||
import CommunityLinks from '../(components)/community-links'
|
||||
|
||||
export default function CommunityPage() {
|
||||
return (
|
||||
<div className='flex-1 space-y-6 p-6 md:p-8'>
|
||||
<div className='space-y-1'>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<UsersIcon className='h-6 w-6 text-primary' />
|
||||
<h2 className='text-3xl font-bold tracking-tight'>Community</h2>
|
||||
</div>
|
||||
<p className='text-muted-foreground'>Connect with other users and find support</p>
|
||||
</div>
|
||||
|
||||
<div className=''>
|
||||
<CommunityLinks />
|
||||
</div>
|
||||
// p-4 on mobile, matching every other dashboard section. This page was the
|
||||
// only one still starting at p-6, and the only one absent from the 375px
|
||||
// overflow guard, which is presumably how it was missed.
|
||||
<div className='flex-1 space-y-6 p-4 sm:p-6 md:p-8'>
|
||||
<PageHeader
|
||||
icon={UsersIcon}
|
||||
title='Community'
|
||||
description='Connect with other users and find support'
|
||||
/>
|
||||
<CommunityLinks />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { MessageSquareTextIcon } from 'lucide-react'
|
||||
import RouteTabs from '@/components/shared/route-tabs'
|
||||
import PageHeader from '@/components/shared/page-header'
|
||||
|
||||
// Messaging section shell: shared header + route-based tabs, so the active
|
||||
// tab survives refresh and every view has a shareable URL.
|
||||
@@ -13,17 +14,11 @@ export default function MessagingLayout({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<div className='flex-1 p-4 sm:p-6 md:p-8'>
|
||||
<div className='w-full max-w-3xl'>
|
||||
<div className='mb-4 space-y-1'>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<MessageSquareTextIcon className='h-6 w-6 text-primary' />
|
||||
<h2 className='text-2xl sm:text-3xl font-bold tracking-tight'>
|
||||
Messaging
|
||||
</h2>
|
||||
</div>
|
||||
<p className='text-muted-foreground'>
|
||||
Send messages and view your SMS history
|
||||
</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
icon={MessageSquareTextIcon}
|
||||
title='Messaging'
|
||||
description='Send messages and view your SMS history'
|
||||
/>
|
||||
|
||||
<RouteTabs
|
||||
className='mb-6'
|
||||
|
||||
@@ -102,7 +102,11 @@ export default function DashboardPage() {
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => window.open('https://textbee.dev/quickstart', '_blank')}
|
||||
onClick={() => window.open(
|
||||
'https://textbee.dev/quickstart',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)}
|
||||
>
|
||||
<ArrowUpRightIcon className='h-4 w-4' />
|
||||
Quick Start
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { Webhook } from 'lucide-react'
|
||||
import RouteTabs from '@/components/shared/route-tabs'
|
||||
import PageHeader from '@/components/shared/page-header'
|
||||
|
||||
// Webhooks section shell: subscriptions management and delivery history are
|
||||
// route-based tabs, so the active view survives refresh.
|
||||
export default function WebhooksLayout({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<div className='flex-1 p-4 sm:p-6 md:p-8'>
|
||||
<div className='mb-4 space-y-1'>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Webhook className='h-6 w-6 text-primary' />
|
||||
<h2 className='text-2xl sm:text-3xl font-bold tracking-tight'>
|
||||
Webhooks
|
||||
</h2>
|
||||
</div>
|
||||
<p className='text-muted-foreground'>
|
||||
Get notified at your endpoints when SMS events happen
|
||||
</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
icon={Webhook}
|
||||
title='Webhooks'
|
||||
description='Get notified at your endpoints when SMS events happen'
|
||||
/>
|
||||
|
||||
<RouteTabs
|
||||
className='mb-6'
|
||||
|
||||
@@ -82,7 +82,7 @@ export const JoinCommunityModal = () => {
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={() => {
|
||||
window.open(ExternalLinks.discord, '_blank')
|
||||
window.open(ExternalLinks.discord, '_blank', 'noopener,noreferrer')
|
||||
handleJoined()
|
||||
}}
|
||||
className='gap-2'
|
||||
|
||||
40
web/components/shared/page-header.tsx
Normal file
40
web/components/shared/page-header.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { ComponentType, ReactNode } from 'react'
|
||||
|
||||
type PageHeaderProps = {
|
||||
title: string
|
||||
description?: string
|
||||
icon?: ComponentType<{ className?: string }>
|
||||
actions?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Section heading shared by the dashboard's route layouts.
|
||||
*
|
||||
* The same icon plus heading plus description block was written out four
|
||||
* times, and community/page.tsx had drifted: it kept an unconditional
|
||||
* text-3xl and no mobile padding step while every other section had moved to
|
||||
* a responsive size. One definition makes that class of drift impossible.
|
||||
*/
|
||||
export default function PageHeader({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
actions,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<div className='mb-4 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between'>
|
||||
<div className='space-y-1'>
|
||||
<div className='flex items-center space-x-2'>
|
||||
{Icon && <Icon className='h-6 w-6 text-primary' />}
|
||||
<h2 className='text-2xl font-bold tracking-tight sm:text-3xl'>
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
{description && (
|
||||
<p className='text-muted-foreground'>{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,8 +12,16 @@ const AUTHED_PAGES = [
|
||||
'/dashboard/messaging',
|
||||
'/dashboard/messaging/bulk',
|
||||
'/dashboard/messaging/history',
|
||||
'/dashboard/messaging/api-guide',
|
||||
'/dashboard/webhooks',
|
||||
'/dashboard/webhooks/deliveries',
|
||||
'/dashboard/account',
|
||||
'/dashboard/account/profile',
|
||||
'/dashboard/account/security',
|
||||
'/dashboard/account/support',
|
||||
// Community was the only section missing from this list, and also the only
|
||||
// one that never got the mobile padding pass. Those two facts are related.
|
||||
'/dashboard/community',
|
||||
]
|
||||
|
||||
async function expectNoHorizontalScroll(page: import('@playwright/test').Page) {
|
||||
@@ -39,6 +47,26 @@ test.describe('no horizontal overflow at 375px (mocked API)', () => {
|
||||
})
|
||||
}
|
||||
|
||||
// The tightest grid in the app only exists inside a dialog, so a guard that
|
||||
// never opens one cannot see it.
|
||||
test('community share dialog fits at 375px', async ({ page, context }) => {
|
||||
await authenticate(context)
|
||||
await mockApi(page)
|
||||
await page.goto('/dashboard/community')
|
||||
|
||||
await page.getByRole('button', { name: /Share textbee\.dev/ }).click()
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog.getByText('Choose your platform')).toBeVisible()
|
||||
|
||||
await expectNoHorizontalScroll(page)
|
||||
|
||||
// The dialog itself must not overflow its own box either.
|
||||
const overflows = await dialog.evaluate(
|
||||
(el) => el.scrollWidth > el.clientWidth + 1
|
||||
)
|
||||
expect(overflows, 'share dialog must not scroll sideways').toBe(false)
|
||||
})
|
||||
|
||||
test('login page', async ({ page }) => {
|
||||
await mockApi(page)
|
||||
await page.goto('/login')
|
||||
|
||||
Reference in New Issue
Block a user