Add contact form

This commit is contained in:
MartinBraquet
2025-10-18 02:20:31 +02:00
parent 46ffefbbb9
commit 065d489869
12 changed files with 269 additions and 25 deletions

View File

@@ -58,6 +58,7 @@ import {sendDiscordMessage} from "common/discord/core";
import {getMessagesCount} from "api/get-messages-count";
import {createVote} from "api/create-vote";
import {vote} from "api/vote";
import {contact} from "api/contact";
const allowCorsUnrestricted: RequestHandler = cors({})
@@ -160,6 +161,7 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
'create-compatibility-question': createCompatibilityQuestion,
'create-vote': createVote,
'vote': vote,
'contact': contact,
'compatible-profiles': getCompatibleProfilesHandler,
'search-location': searchLocation,
'search-near-city': searchNearCity,

View File

@@ -0,0 +1,41 @@
import {APIError, APIHandler} from './helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {insert} from 'shared/supabase/utils'
import {tryCatch} from 'common/util/try-catch'
import {sendDiscordMessage} from "common/discord/core";
import {jsonToMarkdown} from "common/md";
// Stores a contact message into the `contact` table
// Web sends TipTap JSON in `content`; we store it as string in `description`.
// If optional content metadata is provided, we include it; otherwise we fall back to user-centric defaults.
export const contact: APIHandler<'contact'> = async (
{content, userId},
_auth
) => {
const pg = createSupabaseDirectClient()
const {error} = await tryCatch(
insert(pg, 'contact', {
user_id: userId,
content: JSON.stringify(content),
})
)
if (error) throw new APIError(500, 'Failed to submit contact message')
const continuation = async () => {
try {
const md = jsonToMarkdown(content)
const message: string = `**New Contact Message**\n${md}`
await sendDiscordMessage(message, 'contact')
} catch (e) {
console.error('Failed to send discord contact', e)
}
}
return {
success: true,
result: {},
continue: continuation,
}
}

View File

@@ -8,30 +8,7 @@ import { updateUser } from 'shared/supabase/users'
import { tryCatch } from 'common/util/try-catch'
import { insert } from 'shared/supabase/utils'
import {sendDiscordMessage} from "common/discord/core";
function extractTextFromBio(bio: any): string {
try {
const texts: string[] = []
const visit = (node: any) => {
if (!node) return
if (Array.isArray(node)) {
for (const item of node) visit(item)
return
}
if (typeof node === 'object') {
for (const [k, v] of Object.entries(node)) {
if (k === 'text' && typeof v === 'string') texts.push(v)
else visit(v as any)
}
}
}
visit(bio)
// Remove extra whitespace and join
return texts.map((t) => t.trim()).filter(Boolean).join(' ')
} catch {
return ''
}
}
import {jsonToMarkdown} from "common/md";
export const createProfile: APIHandler<'create-profile'> = async (body, auth) => {
const pg = createSupabaseDirectClient()
@@ -75,7 +52,7 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
try {
let message: string = `[**${user.name}**](https://www.compassmeet.com/${user.username}) just created a profile`
if (body.bio) {
const bioText = extractTextFromBio(body.bio)
const bioText = jsonToMarkdown(body.bio)
if (bioText) message += `\n > ${bioText}`
}
await sendDiscordMessage(message, 'members')

View File

@@ -0,0 +1,14 @@
create table if not exists
contact (
id text default uuid_generate_v4 () not null,
created_time timestamp with time zone default now(),
user_id text,
content jsonb
);
-- Foreign Keys
alter table contact
add constraint contact_user_id_fkey foreign key (user_id) references users (id);
-- Row Level Security
alter table contact enable row level security;

View File

@@ -555,6 +555,16 @@ export const API = (_apiTypeCheck = {
radius: z.number().min(1).max(500),
}),
},
'contact': {
method: 'POST',
authed: false,
rateLimited: true,
returns: {} as any,
props: z.object({
content: contentSchema,
userId: z.string().optional(),
}),
},
'get-messages-count': {
method: 'GET',
authed: false,

View File

@@ -6,6 +6,7 @@ export const sendDiscordMessage = async (content: string, channel: string) => {
general: process.env.DISCORD_WEBHOOK_GENERAL,
health: process.env.DISCORD_WEBHOOK_HEALTH,
reports: process.env.DISCORD_WEBHOOK_REPORTS,
contact: process.env.DISCORD_WEBHOOK_CONTACT,
}[channel]
if (IS_DEV) webhookUrl = process.env.DISCORD_WEBHOOK_DEV

89
common/src/md.ts Normal file
View File

@@ -0,0 +1,89 @@
import type { JSONContent } from '@tiptap/core'
export function jsonToMarkdown(node: JSONContent): string {
if (!node) return ''
// Text node
if (node.type === 'text') {
let text = node.text || ''
if (node.marks) {
for (const mark of node.marks) {
switch (mark.type) {
case 'bold':
text = `**${text}**`
break
case 'italic':
text = `*${text}*`
break
case 'strike':
text = `~~${text}~~`
break
case 'code':
text = `\`${text}\``
break
case 'link':
text = `[${text}](${mark.attrs?.href ?? ''})`
break
}
}
}
return text
}
// Non-text nodes: recursively process children
const content = (node.content || []).map(jsonToMarkdown).join('')
switch (node.type) {
case 'paragraph':
return `${content}\n\n`
case 'heading': {
const level = node.attrs?.level || 1
return `${'#'.repeat(level)} ${content}\n\n`
}
case 'bulletList':
return `${content}`
case 'orderedList':
return `${content}`
case 'listItem':
return `- ${content}\n`
case 'blockquote':
return content
.split('\n')
.map((line) => (line ? `> ${line}` : ''))
.join('\n') + '\n\n'
case 'codeBlock':
return `\`\`\`\n${content}\n\`\`\`\n\n`
case 'horizontalRule':
return `---\n\n`
case 'hardBreak':
return ` \n`
default:
return content
}
}
// function extractTextFromJsonb(bio: JSONContent): string {
// try {
// const texts: string[] = []
// const visit = (node: any) => {
// if (!node) return
// if (Array.isArray(node)) {
// for (const item of node) visit(item)
// return
// }
// if (typeof node === 'object') {
// for (const [k, v] of Object.entries(node)) {
// if (k === 'text' && typeof v === 'string') texts.push(v)
// else visit(v as any)
// }
// }
// }
// visit(bio)
// // Remove extra whitespace and join
// return texts.map((t) => t.trim()).filter(Boolean).join(' ')
// } catch {
// return ''
// }
// }

View File

@@ -22,6 +22,7 @@ export const secrets = (
'DISCORD_WEBHOOK_GENERAL',
'DISCORD_WEBHOOK_HEALTH',
'DISCORD_WEBHOOK_REPORTS',
'DISCORD_WEBHOOK_CONTACT',
// Some typescript voodoo to keep the string literal types while being not readonly.
] as const
).concat()

View File

@@ -44,6 +44,35 @@ export type Database = {
}
Relationships: []
}
contact: {
Row: {
content: Json | null
created_time: string | null
id: string
user_id: string | null
}
Insert: {
content?: Json | null
created_time?: string | null
id?: string
user_id?: string | null
}
Update: {
content?: Json | null
created_time?: string | null
id?: string
user_id?: string | null
}
Relationships: [
{
foreignKeyName: 'contact_user_id_fkey'
columns: ['user_id']
isOneToOne: false
referencedRelation: 'users'
referencedColumns: ['id']
}
]
}
love_answers: {
Row: {
created_time: string

View File

@@ -0,0 +1,57 @@
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {useUser} from 'web/hooks/use-user'
import {TextEditor, useTextEditor} from "web/components/widgets/editor";
import {JSONContent} from "@tiptap/core";
import {MAX_DESCRIPTION_LENGTH} from "common/envs/constants";
import {Button} from "web/components/buttons/button";
import {api} from "web/lib/api";
import {Title} from "web/components/widgets/title";
import toast from "react-hot-toast";
export function ContactComponent() {
const user = useUser()
const editor = useTextEditor({
max: MAX_DESCRIPTION_LENGTH,
defaultValue: '',
placeholder: 'Contact us here...',
})
const hideButton = editor?.getText().length == 0
return (
<Col className="mx-2">
<Title className="!mb-2 text-3xl">Contact</Title>
<Col>
<div className={'mb-2'}>
<TextEditor
editor={editor}
/>
</div>
{!hideButton && (
<Row className="right-1 justify-between gap-2">
<Button
size="xs"
onClick={async () => {
if (!editor) return
const data = {
content: editor.getJSON() as JSONContent,
userId: user?.id,
};
const result = await api('contact', data).catch(() => {
toast.error('Failed to contact — try again or contact us...')
})
if (!result) return
editor.commands.clearContent()
toast.success('Thank you for your message!')
}}
>
Submit
</Button>
</Row>
)}
</Col>
</Col>
)
}

View File

@@ -25,6 +25,7 @@ import {Profile} from 'common/love/profile'
import {NotificationsIcon, SolidNotificationsIcon} from './notifications-icon'
import {IS_MAINTENANCE} from "common/constants";
import {MdThumbUp} from "react-icons/md";
import {FaEnvelope} from "react-icons/fa";
export function LovePage(props: {
trackPageView: string | false
@@ -115,6 +116,7 @@ const Messages = {name: 'Messages', href: '/messages', icon: PrivateMessagesIcon
const Social = {name: 'Social', href: '/social', icon: LinkIcon};
const Organization = {name: 'Organization', href: '/organization', icon: GlobeAltIcon};
const Vote = {name: 'Vote', href: '/vote', icon: MdThumbUp};
const Contact = {name: 'Contact', href: '/contact', icon: FaEnvelope};
const base = [
About,
@@ -122,6 +124,7 @@ const base = [
Vote,
Social,
Organization,
Contact,
]
function getBottomNavigation(user: User, profile: Profile | null | undefined) {

20
web/pages/contact.tsx Normal file
View File

@@ -0,0 +1,20 @@
import {LovePage} from 'web/components/love-page'
import {SEO} from 'web/components/SEO'
import {ContactComponent} from "web/components/contact";
export default function ContactPage() {
return (
<LovePage
trackPageView={'vote page'}
className={'relative p-2 sm:pt-0'}
>
<SEO
title={`Contact`}
description={'Contact us'}
url={`/contact`}
/>
<ContactComponent/>
</LovePage>
)
}