diff --git a/.env.example b/.env.example index 689eb4dfa0..ee1246cb99 100644 --- a/.env.example +++ b/.env.example @@ -489,8 +489,7 @@ REVENUECAT_WEBHOOK_SECRET="sample_revenuecat_webhook_secret" # Reject sandbox in-app purchases in production mode (true/false) IN_APP_PURCHASE_REJECT_SANDBOX="false" -# ============================================================================== -# 15. Telemetry, Observability & Analytics +# =======================================================================# 15. Telemetry, Observability & Analytics # ============================================================================== # Sentry DSN for error reporting NEXT_PUBLIC_SENTRY_DSN="https://sample_sentry_key@o0.ingest.sentry.io/0000000" @@ -516,3 +515,10 @@ NEXT_PUBLIC_FACEBOOK_PIXEL="123456789012345" FACEBOOK_PIXEL_ACCESS_TOKEN="sample_facebook_pixel_access_token" # Discord Support Server Widget URL NEXT_PUBLIC_DISCORD_SUPPORT="https://discord.gg/example" +# reCAPTCHA Settings (invisible v2 on anonymous preview comments, optional; unset = no captcha) +# RECAPTCHA_SITE_KEY="" # Your reCAPTCHA v2 invisible site key +# RECAPTCHA_SECRET_KEY="" # Your reCAPTCHA v2 invisible secret key + +# Provider visibility / migration +# HIDDEN_PROVIDERS="tiktok" # comma-separated identifiers hidden from the add-channel screen +# MIGRATE_PROVIDERS="tiktok:tiktok-business" # comma-separated "old:new" pairs, a reconnect of "old" goes through "new" and the channel is migrated in place diff --git a/.zcodeignore b/.zcodeignore new file mode 100644 index 0000000000..cfb301772d --- /dev/null +++ b/.zcodeignore @@ -0,0 +1,104 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. + +# compiled output +dist +tmp +/out-tsc +.env +.apple +# dependencies +node_modules + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace +.vscode/* + +# IDE - VSCode +.vscode/ +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# misc +/.sass-cache +/connect.lock +/coverage +/libpeerconnection.log +npm-debug.log +yarn-error.log +testem.log +/typings + +# System Files +.DS_Store +Thumbs.db + +.nx/cache +.nx/workspace-data + +# Next.js +.next + +# Vim files +**/*.swp +**/*.swo + +# Temporary files +*.orig +*.~*~ +*.tsbuildinfo + +# ignore Secrets folder +.secrets/ +libraries/plugins/src/plugins.ts +i18n.cache + +# Generated by apps/frontend/scripts/fetch-gtm.mjs on install +apps/frontend/public/g.js +.vercel +.env* +*.env +scripts/*.env +.artifacts/ +.codex-artifacts/ +.playwright-mcp/ +.cloudflared/ + + +# ===== ↑ 以上同步自 .gitignore(「从 .gitignore 同步」只重写以上部分)===== +.git/ +.hg/ +.svn/ +bower_components/ +jspm_packages/ +__pycache__/ +site-packages/ +venv/ +coverage/ +htmlcov/ +lcov-report/ +cmakefiles/ +cmake-build-*/ +bazel-*/ +pods/ +deriveddata/ +storybook-static/ +playwright-report/ +test-results/ +allure-results/ +allure-report/ +cdk.out/ +*.egg-info/ +*.dist-info/ +eggs/ +pip-wheel-metadata/ +wheels/ +# ----- ↑ 以上为 ZCode 默认排除规则(自定义规则请写在本行下方,不会被同步/恢复改动)----- +# 自定义规则写在下方(本行提示可删除) diff --git a/apps/backend/src/api/routes/posts.controller.ts b/apps/backend/src/api/routes/posts.controller.ts index 91c1b44e12..230d2cef3e 100644 --- a/apps/backend/src/api/routes/posts.controller.ts +++ b/apps/backend/src/api/routes/posts.controller.ts @@ -29,6 +29,11 @@ import { Sections, } from '@gitroom/backend/services/auth/permissions/permission.exception.class'; import { PostValidationException } from '@gitroom/backend/api/routes/posts.validation.exception'; +import { + CreatePublicCommentDto, + ResolveCommentDto, +} from '@gitroom/nestjs-libraries/dtos/comments/add.comment.dto'; +import { RealIP } from 'nestjs-real-ip'; @ApiTags('Posts') @Controller('/posts') @@ -71,12 +76,21 @@ export class PostsController { @Post('/:id/comments') async createComment( - @GetOrgFromRequest() org: Organization, @GetUserFromRequest() user: User, @Param('id') id: string, - @Body() body: { comment: string } + @Body() body: CreatePublicCommentDto, + @RealIP() ip: string ) { - return this._postsService.createComment(org.id, user.id, id, body.comment); + return this._postsService.createPublicComment(id, body, user.id, ip); + } + + @Put('/comments/:commentId/resolve') + async resolveComment( + @GetOrgFromRequest() org: Organization, + @Param('commentId') commentId: string, + @Body() body: ResolveCommentDto + ) { + return this._postsService.resolveComment(org.id, commentId, body.resolved); } @Get('/tags') @@ -159,7 +173,10 @@ export class PostsController { } @Get('/group/:group') - getPostsByGroup(@GetOrgFromRequest() org: Organization, @Param('group') group: string) { + getPostsByGroup( + @GetOrgFromRequest() org: Organization, + @Param('group') group: string + ) { return this._postsService.getPostsByGroup(org.id, group); } diff --git a/apps/backend/src/api/routes/public.controller.ts b/apps/backend/src/api/routes/public.controller.ts index fc20695934..40cf3542cc 100644 --- a/apps/backend/src/api/routes/public.controller.ts +++ b/apps/backend/src/api/routes/public.controller.ts @@ -27,6 +27,7 @@ import { promisify } from 'util'; import { OnlyURL } from '@gitroom/nestjs-libraries/dtos/webhooks/webhooks.dto'; import { isSafePublicHttpsUrl } from '@gitroom/nestjs-libraries/dtos/webhooks/webhook.url.validator'; import { ssrfSafeDispatcher } from '@gitroom/nestjs-libraries/dtos/webhooks/ssrf.safe.dispatcher'; +import { CreatePublicCommentDto } from '@gitroom/nestjs-libraries/dtos/comments/add.comment.dto'; const pump = promisify(pipeline); @@ -76,6 +77,15 @@ export class PublicController { return { comments: await this._postsService.getComments(postId) }; } + @Post(`/posts/:id/comments`) + async createComment( + @Param('id') postId: string, + @Body() body: CreatePublicCommentDto, + @RealIP() ip: string + ) { + return this._postsService.createPublicComment(postId, body, null, ip); + } + @Post('/t') async trackEvent( @Res() res: Response, @@ -154,7 +164,6 @@ export class PublicController { } } - @Get('/stream') async streamFile( @Query() query: OnlyURL, diff --git a/apps/frontend/src/app/(app)/(preview)/p/[id]/layout.tsx b/apps/frontend/src/app/(app)/(preview)/p/[id]/layout.tsx index aed33f16a8..8c6e48dcdf 100644 --- a/apps/frontend/src/app/(app)/(preview)/p/[id]/layout.tsx +++ b/apps/frontend/src/app/(app)/(preview)/p/[id]/layout.tsx @@ -3,7 +3,7 @@ import { PreviewWrapper } from '@gitroom/frontend/components/preview/preview.wra export default async function AppLayout({ children }: { children: ReactNode }) { return ( -
+
{children}
); diff --git a/apps/frontend/src/app/(app)/(preview)/p/[id]/page.tsx b/apps/frontend/src/app/(app)/(preview)/p/[id]/page.tsx index 17a6b60719..64c0fbda9c 100644 --- a/apps/frontend/src/app/(app)/(preview)/p/[id]/page.tsx +++ b/apps/frontend/src/app/(app)/(preview)/p/[id]/page.tsx @@ -6,6 +6,8 @@ import { getBrandNameServerSide } from '@gitroom/helpers/utils/is.general.server import SafeImage from '@gitroom/react/helpers/safe.image'; import Link from 'next/link'; import { CommentsComponents } from '@gitroom/frontend/components/preview/comments.components'; +import { PreviewCommentsProvider } from '@gitroom/frontend/components/preview/preview.comments.context'; +import { PostContentClient } from '@gitroom/frontend/components/preview/post.content.client'; import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc'; import { VideoOrImage } from '@gitroom/react/helpers/video.or.image'; @@ -40,107 +42,102 @@ export default async function Auth( const t = await getT(); if (!post.length) { return ( -
+
{t('post_not_found', 'Post not found')}
); } return ( -
-
-
-
-
-
- -
- -
-
- - - - - - -
- -
+ p.id)} + organizationId={post[0].organizationId} + > +
+
+ +
+ +
+
+ + + + + + +
+ +
+
+
+ {t('publication_date', 'Publication Date:')}{' '} + + +
-
-
{!!searchParams?.share && ( -
+ <> +
-
+ )} -
- {t('publication_date', 'Publication Date:')}{' '} - -
-
-
-
-
+
+
{post.map((p: any, index: number) => (
-
+
-
+
{post[0].integration.name}
-
+
{post[0].integration.providerIdentifier}
-
-
-

+
+
+

{post[0].integration.name}

- + @{post[0].integration.profile} {index === 0 && ( @@ -150,40 +147,40 @@ export default async function Auth( /> )}
-
-
+ -
- {JSON.parse(p?.image || '[]').map((p: any) => ( -
- -
- ))} -
+ {!!JSON.parse(p?.image || '[]').length && ( +
+ {JSON.parse(p?.image || '[]').map((p: any) => ( +
+ +
+ ))} +
+ )}

))}
-
-
-
- +
+
+ +
-
+ ); } diff --git a/apps/frontend/src/app/(app)/layout.tsx b/apps/frontend/src/app/(app)/layout.tsx index 5e4852d884..2279aa1360 100644 --- a/apps/frontend/src/app/(app)/layout.tsx +++ b/apps/frontend/src/app/(app)/layout.tsx @@ -92,6 +92,7 @@ export default async function AppLayout({ children }: { children: ReactNode }) { googleAdsId={process.env.NEXT_PUBLIC_GTM_ID} googleAdsTrialTracking={process.env.NEXT_PUBLIC_TRACKING_TRIAL} language={language} + recaptchaSiteKey={process.env.RECAPTCHA_SITE_KEY || ''} mediaProcessing={ process.env.STORAGE_PROVIDER === 'cloudflare' && !!process.env.RUNPOD_API_KEY && diff --git a/apps/frontend/src/app/global.scss b/apps/frontend/src/app/global.scss index a900a44d4f..0c6d5cc031 100644 --- a/apps/frontend/src/app/global.scss +++ b/apps/frontend/src/app/global.scss @@ -822,3 +822,8 @@ html[dir='rtl'] [dir='ltr'] { text-shadow: -1px -1px 0 black, 1px -1px 0 black, -1px 1px 0 black, 1px 1px 0 black; } + +/* Preview page: text that can be selected to leave an inline comment */ +.preview-comment-cursor { + cursor: url("data:image/svg+xml,%3Csvg xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22 width%3D%2228%22 height%3D%2228%22 viewBox%3D%220 0 28 28%22%3E%3Cg fill%3D%22none%22 stroke-linecap%3D%22round%22 stroke-linejoin%3D%22round%22%3E%3Cpath d%3D%22M3 4h6M3 24h6M6 4v20%22 stroke%3D%22%23fff%22 stroke-width%3D%224%22%2F%3E%3Cpath d%3D%22M3 4h6M3 24h6M6 4v20%22 stroke%3D%22%23000%22 stroke-width%3D%221.6%22%2F%3E%3Cpath d%3D%22M14 4h10a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2h-6l-4 3v-3a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z%22 fill%3D%22%23612bd3%22 stroke%3D%22%23fff%22 stroke-width%3D%221.5%22%2F%3E%3Cpath d%3D%22M17 8h6M17 11h4%22 stroke%3D%22%23fff%22 stroke-width%3D%221.5%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E") 6 14, text; +} diff --git a/apps/frontend/src/components/admin/admin-stats.component.tsx b/apps/frontend/src/components/admin/admin-stats.component.tsx index 2673eaa3bd..79f66b690e 100644 --- a/apps/frontend/src/components/admin/admin-stats.component.tsx +++ b/apps/frontend/src/components/admin/admin-stats.component.tsx @@ -28,6 +28,7 @@ interface StatsResponse { publishingChannels?: StatsBlock; scheduledChannels?: StatsBlock; activeOrgsBySource?: StatsBlock; + connectedClients?: StatsBlock; } const isoDaysAgo = (days: number) => { @@ -289,6 +290,14 @@ export const AdminStatsComponent: FC = () => { />
)} + {data.connectedClients && ( +
+ +
+ )}
@@ -354,6 +363,14 @@ export const AdminStatsComponent: FC = () => { />
)} + {data.connectedClients && ( +
+ +
+ )}
)} diff --git a/apps/frontend/src/components/auth/after.activate.tsx b/apps/frontend/src/components/auth/after.activate.tsx index ef2dee1f1e..70f36c44ab 100644 --- a/apps/frontend/src/components/auth/after.activate.tsx +++ b/apps/frontend/src/components/auth/after.activate.tsx @@ -23,18 +23,20 @@ export const AfterActivate = () => { }, []); const loadCode = useCallback(async () => { if (params.code) { - const { can } = await ( - await fetch(`/auth/activate`, { - method: 'POST', - body: JSON.stringify({ - code: params.code, - datafast_visitor_id, - }), - headers: { - 'Content-Type': 'application/json', - }, - }) - ).json(); + const response = await fetch(`/auth/activate`, { + method: 'POST', + body: JSON.stringify({ + code: params.code, + datafast_visitor_id, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + if (response.headers.get('onboarding')) { + return; + } + const { can } = await response.json(); if (!can) { setShowLoader(false); } diff --git a/apps/frontend/src/components/billing/first.billing.component.tsx b/apps/frontend/src/components/billing/first.billing.component.tsx index 654764817c..91e46dd095 100644 --- a/apps/frontend/src/components/billing/first.billing.component.tsx +++ b/apps/frontend/src/components/billing/first.billing.component.tsx @@ -52,6 +52,7 @@ export const FirstBillingComponent = () => { const user = useUser(); const dub = useDubClickId(); const [stripe, setStripe] = useState>(null); + const [stripeFailed, setStripeFailed] = useState(false); const [tier, setTier] = useState('STANDARD'); const [period, setPeriod] = useState('MONTHLY'); const [dosCheckoutLoading, setDosCheckoutLoading] = useState(false); @@ -67,7 +68,9 @@ export const FirstBillingComponent = () => { if (sharedDosBilling) { return; } - setStripe(loadStripe(stripeClient)); + const stripePromise = loadStripe(stripeClient); + stripePromise.catch(() => setStripeFailed(true)); + setStripe(stripePromise); }, [sharedDosBilling, stripeClient]); const loadCheckout = useCallback(async () => { @@ -272,6 +275,13 @@ export const FirstBillingComponent = () => { : t('billing_continue_dos_checkout', 'Continue to DOS checkout')}
+ ) : stripeFailed ? ( +
+ {t( + 'billing_stripe_load_failed', + 'The payment form could not be loaded. Please disable ad blockers or privacy extensions for this page and reload.' + )} +
) : !isLoading && data && stripe ? ( void) => { }; const data = await (await fetch(`/posts/group/${post.group}`)).json(); + if (!data?.posts?.length) { + toaster.show(t('post_not_found', 'Post not found'), 'warning'); + mutate(); + return; + } const date = !isDuplicate ? null : (await (await fetch('/posts/find-slot')).json()).date; @@ -170,7 +175,7 @@ const usePostActions = (onMutate?: () => void) => { title: ``, }); }, - [integrations, fetch, modal, mutate] + [integrations, fetch, modal, mutate, toaster, t] ); const copyDebugJson = useCallback( diff --git a/apps/frontend/src/components/launches/continue.integration.tsx b/apps/frontend/src/components/launches/continue.integration.tsx index 229150dab1..b21a10234f 100644 --- a/apps/frontend/src/components/launches/continue.integration.tsx +++ b/apps/frontend/src/components/launches/continue.integration.tsx @@ -194,7 +194,11 @@ export const ContinueIntegration: FC<{ jwt: extensionToken, backendUrl, }, - () => {} + () => { + if (chrome.runtime.lastError) { + return; + } + } ); } catch { // Silently ignore — extension may not be available diff --git a/apps/frontend/src/components/launches/menu/menu.tsx b/apps/frontend/src/components/launches/menu/menu.tsx index 9b1d16be51..053d276650 100644 --- a/apps/frontend/src/components/launches/menu/menu.tsx +++ b/apps/frontend/src/components/launches/menu/menu.tsx @@ -158,7 +158,11 @@ export const Menu: FC<{ chrome.runtime.sendMessage( extensionId, { type: 'REMOVE_REFRESH_TOKEN', integrationId: id }, - () => {} + () => { + if (chrome.runtime.lastError) { + return; + } + } ); } catch { // Silently ignore diff --git a/apps/frontend/src/components/media/new.uploader.tsx b/apps/frontend/src/components/media/new.uploader.tsx index 532dc0132a..187f49e989 100644 --- a/apps/frontend/src/components/media/new.uploader.tsx +++ b/apps/frontend/src/components/media/new.uploader.tsx @@ -222,7 +222,7 @@ export function useUppyUploader(props: { }); }); uppy2.on('error', (result) => { - uppy2.clear(); + uppy2.cancelAll(); setLocked(false); props.onEnd(); fileOrderIndex = 0; @@ -298,10 +298,14 @@ export function useUppyUploader(props: { onUploadSuccess(sortedSuccessful.map((p) => p.response.body.saved)); }); uppy2.on('upload-success', (file, response) => { + const current = uppy2.getState().files[file.id]; + if (!current) { + return; + } // @ts-ignore uppy2.setFileState(file.id, { // @ts-ignore - progress: uppy2.getState().files[file.id].progress, + progress: current.progress, // @ts-ignore uploadURL: response.body.Location, response: response, diff --git a/apps/frontend/src/components/preview/comments.components.tsx b/apps/frontend/src/components/preview/comments.components.tsx index e4b658b6fc..630ad20b2d 100644 --- a/apps/frontend/src/components/preview/comments.components.tsx +++ b/apps/frontend/src/components/preview/comments.components.tsx @@ -2,127 +2,524 @@ import { useUser } from '@gitroom/frontend/components/layout/user.context'; import { Button } from '@gitroom/react/form/button'; -import { FC, useCallback, useMemo, useState } from 'react'; +import { Input } from '@gitroom/react/form/input'; +import { Textarea } from '@gitroom/react/form/textarea'; +import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; -import useSWR from 'swr'; -import { FieldValues, SubmitHandler, useForm } from 'react-hook-form'; +import { FormProvider, useForm } from 'react-hook-form'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; -export const RenderComponents: FC<{ - postId: string; -}> = (props) => { - const { postId } = props; - const fetch = useFetch(); - const comments = useCallback(async () => { - return (await fetch(`/public/posts/${postId}/comments`)).json(); - }, [postId]); - const { data, mutate, isLoading } = useSWR('comments', comments); - const mapUsers = useMemo(() => { - return (data?.comments || []).reduce( - (all: any, current: any) => { - all.users[current.userId] = all.users[current.userId] || all.counter++; - return all; - }, - { - users: {}, - counter: 1, +import { useModals } from '@gitroom/frontend/components/layout/new-modal'; +import { useVariables } from '@gitroom/react/helpers/variable.context'; +import { useToaster } from '@gitroom/react/toaster/toaster'; +import clsx from 'clsx'; +import dayjs from 'dayjs'; +import { + PreviewComment, + usePreviewComments, +} from '@gitroom/frontend/components/preview/preview.comments.context'; + +const REVIEWER_NAME_KEY = 'preview-reviewer-name'; + +// Invisible reCAPTCHA v2: one widget for the page. Google only shows a puzzle +// when it considers the visitor risky; otherwise the token comes back at once. +// Google does not report a dismissed puzzle, so an attempt stays pending until +// it gets a token, expires, errors, or the reviewer clicks Post again (which +// abandons the previous attempt with null). +let recaptchaWidget: { + id: number; + resolve?: (token: string | null) => void; +} | null = null; + +const loadRecaptchaToken = async (siteKey: string) => { + if (!(window as any).grecaptcha?.render) { + await new Promise((resolve, reject) => { + (window as any).onPreviewRecaptchaLoad = () => resolve(); + const script = document.createElement('script'); + script.src = + 'https://www.google.com/recaptcha/api.js?onload=onPreviewRecaptchaLoad&render=explicit'; + script.async = true; + script.onerror = () => reject(new Error('recaptcha failed to load')); + document.head.appendChild(script); + }); + } + const grecaptcha = (window as any).grecaptcha; + if (!recaptchaWidget) { + const container = document.createElement('div'); + document.body.appendChild(container); + const widget: NonNullable = { id: 0 }; + const settle = (token: string | null) => { + const resolve = widget.resolve; + widget.resolve = undefined; + resolve?.(token); + }; + widget.id = grecaptcha.render(container, { + sitekey: siteKey, + size: 'invisible', + callback: (token: string) => settle(token), + 'expired-callback': () => settle(null), + 'error-callback': () => settle(null), + }); + recaptchaWidget = widget; + } + const widget = recaptchaWidget; + widget.resolve?.(null); + grecaptcha.reset(widget.id); + return new Promise((resolve) => { + widget.resolve = resolve; + grecaptcha.execute(widget.id); + }); +}; + +const ReviewerNameForm: FC<{ onConfirm: (name: string) => void }> = ({ + onConfirm, +}) => { + const t = useT(); + const form = useForm({ + values: { + name: + (typeof window !== 'undefined' && + localStorage.getItem(REVIEWER_NAME_KEY)) || + '', + }, + mode: 'onChange', + }); + const submit = useCallback( + (values: { name: string }) => { + const name = values.name.trim(); + if (!name) { + return; } - ).users; - }, [data]); - const { handleSubmit, register, setValue } = useForm(); - const submit: SubmitHandler = useCallback( - async (e) => { - setValue('comment', ''); - await fetch(`/posts/${postId}/comments`, { - method: 'POST', - body: JSON.stringify(e), - }); - mutate(); + localStorage.setItem(REVIEWER_NAME_KEY, name); + onConfirm(name); }, - [postId, mutate] + [onConfirm] ); + return ( + +
+
+
+ {t( + 'preview_comment_name_required', + 'We can only post your comment if you provide your name.' + )} +
+ + +
+
+
+ ); +}; + +const CommentComposer: FC<{ + parentId?: string; + onDone?: () => void; +}> = ({ parentId, onDone }) => { const t = useT(); + const user = useUser(); + const fetch = useFetch(); + const toast = useToaster(); + const modals = useModals(); + const { recaptchaSiteKey } = useVariables(); + const { previewId, pending, setPending, mutate } = usePreviewComments(); + const [loading, setLoading] = useState(false); + const form = useForm({ values: { content: '' } }); + const anchor = parentId ? null : pending; + + const askForName = useCallback( + () => + new Promise((resolve) => { + modals.openModal({ + classNames: { + modal: 'bg-transparent text-textColor', + }, + title: t('preview_comment_your_name', 'Your name'), + withCloseButton: true, + onClose: () => resolve(null), + children: (close) => ( + { + resolve(name); + close(); + }} + /> + ), + }); + }), + [t] + ); + + const submit = useCallback( + async (values: { content: string }) => { + const content = values.content.trim(); + if (!content) { + return; + } + + let displayName: string | undefined; + let recaptchaToken: string | undefined; + if (!user?.id) { + const name = await askForName(); + if (!name) { + return; + } + displayName = name; + + if (recaptchaSiteKey) { + const token = await loadRecaptchaToken(recaptchaSiteKey).catch(() => { + toast.show( + t('preview_comment_failed', 'Could not post the comment'), + 'warning' + ); + return null; + }); + if (!token) { + return; + } + recaptchaToken = token; + } + } + + setLoading(true); + try { + const response = await fetch( + user?.id + ? `/posts/${previewId}/comments` + : `/public/posts/${previewId}/comments`, + { + method: 'POST', + body: JSON.stringify({ + content, + ...(parentId ? { parentId } : {}), + ...(anchor + ? { + postId: anchor.postId, + anchorStart: anchor.start, + anchorEnd: anchor.end, + anchorQuote: anchor.quote, + } + : {}), + ...(displayName ? { displayName } : {}), + ...(recaptchaToken ? { recaptchaToken } : {}), + }), + } + ); + + if (!response.ok) { + const body = await response.json().catch(() => ({})); + toast.show( + body?.message || + t('preview_comment_failed', 'Could not post the comment'), + 'warning' + ); + return; + } + + form.reset({ content: '' }); + if (anchor) { + setPending(null); + } + await mutate(); + onDone?.(); + } finally { + setLoading(false); + } + }, + [user?.id, recaptchaSiteKey, previewId, parentId, anchor, askForName] + ); - if (isLoading) { - return <>; - } return ( - <> -
-
-