mirror of
https://github.com/vernu/textbee.git
synced 2026-09-03 03:29:58 +03:00
Merge pull request #310 from vernu/feat/discord-community-links
Add Discord community links across the web app, android app, and emails
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||

|
||||

|
||||

|
||||
[](https://discord.gg/d7vyfBpWbQ)
|
||||
[](https://textbee.dev/discord)
|
||||
|
||||
# textbee.dev - android sms gateway
|
||||
|
||||
@@ -255,4 +255,4 @@ Please feel free to [create an issue](https://github.com/vernu/textbee/issues/ne
|
||||
Please note that if you discover any vulnerability or security issue, we kindly request that you refrain from creating a public issue. Instead, send an email detailing the vulnerability to contact@textbee.dev.
|
||||
|
||||
## For support, feedback, and questions
|
||||
Feel free to reach out to us at contact@textbee.dev or [Join our Discord server](https://discord.gg/d7vyfBpWbQ)
|
||||
Feel free to reach out to us at contact@textbee.dev or [Join our Discord server](https://textbee.dev/discord)
|
||||
|
||||
@@ -240,6 +240,19 @@ fun SettingsScreen(
|
||||
}
|
||||
)
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Forum,
|
||||
title = "Join our Discord",
|
||||
subtitle = "Get help from the community",
|
||||
onClick = {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/discord")))
|
||||
},
|
||||
trailing = {
|
||||
Icon(Icons.Default.OpenInBrowser, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
)
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Share,
|
||||
title = "Share textbee",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
<td align='center' style='font:12px/1.6 Arial, Helvetica, sans-serif; color:#6b7280;'>
|
||||
<div>© 2025 {{brandName}}. All rights reserved.</div>
|
||||
<div class='muted' style='margin-top:4px;'>Manage notifications in <a href='https://app.textbee.dev/dashboard/account/' style='color:#6b7280; text-decoration:underline;'>Account settings</a>.</div>
|
||||
<div class='muted' style='margin-top:4px;'>Questions? Ask the <a href='https://textbee.dev/discord' style='color:#6b7280; text-decoration:underline;'>community on Discord</a>.</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
|
||||
<p>We will review your request and respond to the email address you provided. If you have any additional information to share, please reply to this email.</p>
|
||||
|
||||
<p>While you wait, the community on Discord may already have an answer. <a href='https://textbee.dev/discord' style='color: #2563eb;'>Join the textbee Discord</a>.</p>
|
||||
|
||||
<div class='footer'>
|
||||
<p>© {{currentYear}} textbee.dev.</p>
|
||||
</div>
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
<div class='community-section'>
|
||||
<p style='font-size: 14px; margin-bottom: 15px;'>Join our community!</p>
|
||||
<a
|
||||
href='https://discord.gg/d7vyfBpWbQ'
|
||||
href='https://textbee.dev/discord'
|
||||
style='display: inline-block; margin: 0 10px; color: #7289DA; text-decoration: none;'
|
||||
>
|
||||
<img
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
<div class='community-section'>
|
||||
<p style='font-size: 14px; margin-bottom: 15px;'>Join our community!</p>
|
||||
<a
|
||||
href='https://discord.gg/d7vyfBpWbQ'
|
||||
href='https://textbee.dev/discord'
|
||||
style='display: inline-block; margin: 0 10px; color: #7289DA; text-decoration: none;'
|
||||
>
|
||||
<img
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
<div class='community-section'>
|
||||
<p style='font-size: 14px; margin-bottom: 15px;'>Join our community!</p>
|
||||
<a
|
||||
href='https://discord.gg/d7vyfBpWbQ'
|
||||
href='https://textbee.dev/discord'
|
||||
style='display: inline-block; margin: 0 10px; color: #7289DA; text-decoration: none;'
|
||||
>
|
||||
<img
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { MessageSquare } from 'lucide-react'
|
||||
import { markDiscordJoined } from '@/lib/discord-community'
|
||||
import { ExternalLinks } from '@/config/external-links'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function CommunitySupportCard() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Get help from the community</CardTitle>
|
||||
<CardDescription>
|
||||
Ask questions and get answers from other textbee users and the team on
|
||||
our Discord server. It is often the fastest way to get unblocked.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild variant='outline'>
|
||||
<Link
|
||||
href={ExternalLinks.discord}
|
||||
prefetch={false}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
onClick={markDiscordJoined}
|
||||
>
|
||||
<MessageSquare className='mr-2 h-4 w-4' />
|
||||
Join the Discord
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { MessageSquare, X } from 'lucide-react'
|
||||
import {
|
||||
DISCORD_STORAGE_KEYS,
|
||||
dismissDiscordBanner,
|
||||
openDiscordInvite,
|
||||
safeGetDiscordFlag,
|
||||
} from '@/lib/discord-community'
|
||||
|
||||
export default function JoinDiscordBanner() {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const dismissed =
|
||||
safeGetDiscordFlag(DISCORD_STORAGE_KEYS.BANNER_DISMISSED) === '1'
|
||||
const joined =
|
||||
safeGetDiscordFlag(DISCORD_STORAGE_KEYS.HAS_JOINED) === 'true'
|
||||
setIsVisible(!dismissed && !joined)
|
||||
}, [])
|
||||
|
||||
const hide = () => {
|
||||
dismissDiscordBanner()
|
||||
setIsVisible(false)
|
||||
}
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
return (
|
||||
<Alert className='bg-linear-to-r from-brand-500 to-brand-600 text-white'>
|
||||
<AlertDescription className='flex flex-col items-center gap-2 sm:flex-row md:gap-4'>
|
||||
<span className='w-full text-center text-sm font-medium sm:flex-1 sm:text-left md:text-base'>
|
||||
Questions or feedback? Get quick answers from the textbee community on
|
||||
Discord.
|
||||
</span>
|
||||
<div className='mt-2 flex w-full items-center justify-center gap-2 sm:mt-0 sm:w-auto sm:justify-end'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='border-transparent bg-white text-brand-700 hover:bg-brand-50 text-xs md:text-sm'
|
||||
onClick={() => {
|
||||
openDiscordInvite()
|
||||
hide()
|
||||
}}
|
||||
>
|
||||
<MessageSquare className='mr-2 h-4 w-4' />
|
||||
Join Discord
|
||||
</Button>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Dismiss Discord invitation'
|
||||
onClick={hide}
|
||||
className='rounded-md p-1 text-white/80 transition-colors hover:bg-white/15 hover:text-white focus:outline-none focus:ring-2 focus:ring-white'
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
</button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
@@ -9,12 +9,15 @@ import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
MessageSquare,
|
||||
PartyPopper,
|
||||
RefreshCw,
|
||||
Send,
|
||||
Sparkles,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ExternalLinks } from '@/config/external-links'
|
||||
import { markDiscordJoined } from '@/lib/discord-community'
|
||||
import GetStartedCardSkeleton from './skeleton'
|
||||
import StepActions from './step-actions'
|
||||
import { useOnboarding } from './use-onboarding'
|
||||
@@ -95,6 +98,9 @@ export default function GetStartedCard() {
|
||||
<p className='mt-1 text-sm text-muted-foreground'>
|
||||
Your SMS gateway is up and running.
|
||||
</p>
|
||||
<p className='mt-1 text-sm text-muted-foreground'>
|
||||
Join our Discord to share what you're building and get help fast.
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-wrap items-center justify-center gap-2'>
|
||||
<Button size='sm' asChild>
|
||||
@@ -103,6 +109,18 @@ export default function GetStartedCard() {
|
||||
Send a message
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant='outline' size='sm' asChild>
|
||||
<Link
|
||||
href={ExternalLinks.discord}
|
||||
prefetch={false}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
onClick={markDiscordJoined}
|
||||
>
|
||||
<MessageSquare className='h-4 w-4' />
|
||||
Join the community
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant='ghost' size='sm' onClick={dismissCelebration}>
|
||||
Dismiss
|
||||
</Button>
|
||||
|
||||
@@ -6,10 +6,12 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import SupportForm from '../../(components)/account/support-form'
|
||||
import CommunitySupportCard from '../../(components)/account/community-support-card'
|
||||
|
||||
export default function SupportPage() {
|
||||
return (
|
||||
<div className='max-w-2xl'>
|
||||
<div className='max-w-2xl space-y-4'>
|
||||
<CommunitySupportCard />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Contact Support</CardTitle>
|
||||
|
||||
@@ -9,7 +9,9 @@ import UpdateAppModal from './(components)/devices/update-app-modal'
|
||||
import UpdateAppNotificationBar from './(components)/devices/update-app-notification-bar'
|
||||
import VerifyEmailAlert from './(components)/alerts/verify-email-alert'
|
||||
import PastDueBillingAlert from './(components)/alerts/past-due-billing-alert'
|
||||
import JoinDiscordBanner from './(components)/alerts/join-discord-banner'
|
||||
import { SurveyModal } from '@/components/shared/survey-modal'
|
||||
import { JoinCommunityModal } from '@/components/shared/join-community-modal'
|
||||
import Footer from '@/components/shared/footer'
|
||||
import ThemeToggle from '@/components/shared/theme-toggle'
|
||||
import CommandMenu from './(components)/search/command-menu'
|
||||
@@ -93,6 +95,7 @@ export default function DashboardLayout({
|
||||
<PastDueBillingAlert />
|
||||
<AccountDeletionAlert />
|
||||
<UpgradeToProAlert />
|
||||
<JoinDiscordBanner />
|
||||
</div>
|
||||
<main id='main-content' tabIndex={-1}>
|
||||
{children}
|
||||
@@ -122,6 +125,7 @@ export default function DashboardLayout({
|
||||
|
||||
<SurveyModal />
|
||||
<UpdateAppModal />
|
||||
<JoinCommunityModal />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ComponentType } from 'react'
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatError } from '@/lib/utils/errorHandler'
|
||||
import { ExternalLinks } from '@/config/external-links'
|
||||
|
||||
type ErrorStateProps = {
|
||||
error: unknown
|
||||
@@ -45,6 +46,18 @@ export default function ErrorState({
|
||||
Try again
|
||||
</Button>
|
||||
)}
|
||||
<p className='mt-1 text-xs text-muted-foreground'>
|
||||
Still stuck? Ask the{' '}
|
||||
<a
|
||||
href={ExternalLinks.discord}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='font-medium text-primary hover:underline'
|
||||
>
|
||||
community on Discord
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Routes } from '@/config/routes'
|
||||
import { ExternalLinks } from '@/config/external-links'
|
||||
import { Activity } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
@@ -11,6 +12,7 @@ const links = [
|
||||
{ label: 'Quick start', href: Routes.quickstart },
|
||||
{ label: 'Download app', href: Routes.downloadAndroidApp },
|
||||
{ label: 'Contribute', href: Routes.contribute },
|
||||
{ label: 'Discord', href: ExternalLinks.discord },
|
||||
{ label: 'Privacy', href: Routes.privacyPolicy },
|
||||
{ label: 'Terms', href: Routes.termsOfService },
|
||||
{ label: 'Refund', href: Routes.refundPolicy },
|
||||
|
||||
@@ -8,13 +8,13 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ExternalLinks } from '@/config/external-links'
|
||||
|
||||
// Constants for localStorage keys and timing
|
||||
const STORAGE_KEYS = {
|
||||
LAST_SHOWN: 'discord_modal_last_shown',
|
||||
HAS_JOINED: 'discord_modal_has_joined',
|
||||
}
|
||||
import {
|
||||
DISCORD_STORAGE_KEYS as STORAGE_KEYS,
|
||||
markDiscordJoined,
|
||||
openDiscordInvite,
|
||||
recordDiscordModalShown,
|
||||
safeGetDiscordFlag,
|
||||
} from '@/lib/discord-community'
|
||||
|
||||
const SHOW_INTERVAL = 1 * 24 * 60 * 60 * 1000 // 1 days in milliseconds
|
||||
const RANDOM_CHANCE = 0.2 // 20% chance to show when eligible
|
||||
@@ -24,16 +24,23 @@ export const JoinCommunityModal = () => {
|
||||
|
||||
useEffect(() => {
|
||||
const checkAndShowModal = () => {
|
||||
const hasJoined = localStorage.getItem(STORAGE_KEYS.HAS_JOINED) === 'true'
|
||||
const hasJoined =
|
||||
safeGetDiscordFlag(STORAGE_KEYS.HAS_JOINED) === 'true'
|
||||
if (hasJoined) return
|
||||
|
||||
const lastShown = localStorage.getItem(STORAGE_KEYS.LAST_SHOWN)
|
||||
// The banner asks first. Only users who dismissed it without joining
|
||||
// are ever eligible for the modal.
|
||||
const bannerDismissed =
|
||||
safeGetDiscordFlag(STORAGE_KEYS.BANNER_DISMISSED) === '1'
|
||||
if (!bannerDismissed) return
|
||||
|
||||
const lastShown = safeGetDiscordFlag(STORAGE_KEYS.LAST_SHOWN)
|
||||
const now = Date.now()
|
||||
const lastShownTime = lastShown ? parseInt(lastShown) : 0
|
||||
if (!lastShown || now - lastShownTime >= SHOW_INTERVAL) {
|
||||
if (Math.random() < RANDOM_CHANCE) {
|
||||
setIsOpen(true)
|
||||
localStorage.setItem(STORAGE_KEYS.LAST_SHOWN, now.toString())
|
||||
recordDiscordModalShown()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,15 +49,18 @@ export const JoinCommunityModal = () => {
|
||||
checkAndShowModal()
|
||||
|
||||
// Also check when tab becomes visible
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
checkAndShowModal()
|
||||
}
|
||||
})
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
return () =>
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
}, [])
|
||||
|
||||
const handleJoined = () => {
|
||||
localStorage.setItem(STORAGE_KEYS.HAS_JOINED, 'true')
|
||||
markDiscordJoined()
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
@@ -82,8 +92,8 @@ export const JoinCommunityModal = () => {
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={() => {
|
||||
window.open(ExternalLinks.discord, '_blank', 'noopener,noreferrer')
|
||||
handleJoined()
|
||||
openDiscordInvite()
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className='gap-2'
|
||||
>
|
||||
|
||||
@@ -6,6 +6,24 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import type { RateLimitErrorData } from '@/lib/utils/errorHandler'
|
||||
import { checkoutPath } from '@/lib/plans'
|
||||
import { ExternalLinks } from '@/config/external-links'
|
||||
|
||||
function DiscordHelpLine() {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Questions about limits? Ask the{' '}
|
||||
<a
|
||||
href={ExternalLinks.discord}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-2"
|
||||
>
|
||||
community on Discord
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
interface RateLimitErrorProps {
|
||||
errorData?: RateLimitErrorData
|
||||
@@ -35,6 +53,7 @@ export function RateLimitError({
|
||||
or wait for your limit to reset
|
||||
</p>
|
||||
</div>
|
||||
<DiscordHelpLine />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -53,6 +72,7 @@ export function RateLimitError({
|
||||
or wait for your limit to reset
|
||||
</span>
|
||||
</div>
|
||||
<DiscordHelpLine />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ export function polarCustomerPortalRequestUrl(
|
||||
export const ExternalLinks = {
|
||||
patreon: 'https://patreon.com/vernu',
|
||||
github: 'https://github.com/vernu/textbee',
|
||||
discord: 'https://discord.gg/d7vyfBpWbQ',
|
||||
discord: 'https://textbee.dev/discord',
|
||||
polar: 'https://donate.textbee.dev',
|
||||
twitter: 'https://x.com/textbeedotdev',
|
||||
linkedin: 'https://www.linkedin.com/company/textbeedotdev',
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ExternalLinks } from '@/config/external-links'
|
||||
|
||||
export const DISCORD_STORAGE_KEYS = {
|
||||
LAST_SHOWN: 'discord_modal_last_shown',
|
||||
HAS_JOINED: 'discord_modal_has_joined',
|
||||
BANNER_DISMISSED: 'discord_banner_dismissed',
|
||||
}
|
||||
|
||||
function safeSet(key: string, value: string) {
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
} catch {
|
||||
// Private browsing or a full quota, nothing to recover from here.
|
||||
}
|
||||
}
|
||||
|
||||
export function safeGetDiscordFlag(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Suppresses every Discord prompt once the user has taken the link. */
|
||||
export function markDiscordJoined() {
|
||||
safeSet(DISCORD_STORAGE_KEYS.HAS_JOINED, 'true')
|
||||
}
|
||||
|
||||
export function dismissDiscordBanner() {
|
||||
safeSet(DISCORD_STORAGE_KEYS.BANNER_DISMISSED, '1')
|
||||
}
|
||||
|
||||
export function recordDiscordModalShown() {
|
||||
safeSet(DISCORD_STORAGE_KEYS.LAST_SHOWN, Date.now().toString())
|
||||
}
|
||||
|
||||
export function openDiscordInvite() {
|
||||
window.open(ExternalLinks.discord, '_blank', 'noopener,noreferrer')
|
||||
markDiscordJoined()
|
||||
}
|
||||
@@ -15,6 +15,13 @@ const nextConfig = {
|
||||
destination: 'https://dl.textbee.dev',
|
||||
permanent: false,
|
||||
},
|
||||
// The invite itself lives behind textbee.dev/discord, so it can rotate
|
||||
// in one place.
|
||||
{
|
||||
source: '/discord',
|
||||
destination: 'https://textbee.dev/discord',
|
||||
permanent: false,
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user