mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-04-04 06:51:45 -04:00
Add contact form
This commit is contained in:
@@ -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,
|
||||
|
||||
41
backend/api/src/contact.ts
Normal file
41
backend/api/src/contact.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
14
backend/supabase/contact.sql
Normal file
14
backend/supabase/contact.sql
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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
89
common/src/md.ts
Normal 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 ''
|
||||
// }
|
||||
// }
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
57
web/components/contact.tsx
Normal file
57
web/components/contact.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
20
web/pages/contact.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user