feat: provider edit preview

This commit is contained in:
Nevo David 2026-04-17 21:55:47 +07:00
parent 45bdf128e9
commit 7d9b99abf3
9 changed files with 405 additions and 9 deletions

View file

@ -0,0 +1,73 @@
export const dynamic = 'force-dynamic';
import '../global.scss';
import 'react-tooltip/dist/react-tooltip.css';
import '@copilotkit/react-ui/styles.css';
import LayoutContext from '@gitroom/frontend/components/layout/layout.context';
import { ReactNode } from 'react';
import { Plus_Jakarta_Sans } from 'next/font/google';
import clsx from 'clsx';
import { VariableContextComponent } from '@gitroom/react/helpers/variable.context';
import UtmSaver from '@gitroom/helpers/utils/utm.saver';
const jakartaSans = Plus_Jakarta_Sans({
weight: ['600', '500'],
style: ['normal', 'italic'],
subsets: ['latin'],
});
export default async function AppLayout({ children }: { children: ReactNode }) {
return (
<html>
<head>
<link rel="icon" href="/favicon.ico" sizes="any" />
</head>
<body
className={clsx(jakartaSans.className, 'dark text-primary !bg-primary')}
>
<VariableContextComponent
language="en"
storageProvider={
process.env.STORAGE_PROVIDER! as 'local' | 'cloudflare'
}
stripeClient=""
environment={process.env.NODE_ENV!}
backendUrl={process.env.NEXT_PUBLIC_BACKEND_URL!}
plontoKey={process.env.NEXT_PUBLIC_POLOTNO!}
billingEnabled={!!process.env.STRIPE_PUBLISHABLE_KEY}
discordUrl={process.env.NEXT_PUBLIC_DISCORD_SUPPORT!}
frontEndUrl={process.env.FRONTEND_URL!}
isGeneral={!!process.env.IS_GENERAL}
genericOauth={!!process.env.POSTIZ_GENERIC_OAUTH}
oauthLogoUrl={process.env.NEXT_PUBLIC_POSTIZ_OAUTH_LOGO_URL!}
oauthDisplayName={process.env.NEXT_PUBLIC_POSTIZ_OAUTH_DISPLAY_NAME!}
uploadDirectory={process.env.NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY!}
cloudflareUrl={process.env.CLOUDFLARE_BUCKET_URL || ''}
mainUrl={process.env.MAIN_URL || ''}
mcpUrl={process.env.MCP_URL}
dub={false}
facebookPixel={process.env.NEXT_PUBLIC_FACEBOOK_PIXEL!}
telegramBotName={process.env.TELEGRAM_BOT_NAME!}
neynarClientId={process.env.NEYNAR_CLIENT_ID!}
isSecured={!process.env.NOT_SECURED}
disableImageCompression={!!process.env.DISABLE_IMAGE_COMPRESSION}
disableXAnalytics={!!process.env.DISABLE_X_ANALYTICS}
sentryDsn={process.env.NEXT_PUBLIC_SENTRY_DSN!}
extensionId={process.env.EXTENSION_ID || ''}
transloadit={
process.env.TRANSLOADIT_AUTH && process.env.TRANSLOADIT_TEMPLATE
? [
process.env.TRANSLOADIT_AUTH!,
process.env.TRANSLOADIT_TEMPLATE!,
]
: []
}
>
<LayoutContext>
<UtmSaver />
{children}
</LayoutContext>
</VariableContextComponent>
</body>
</html>
);
}

View file

@ -0,0 +1,55 @@
'use client';
import { FC, useEffect, useRef, useState } from 'react';
import {
ProviderPreviewComponent,
type ProviderPreviewHandle,
type ProviderPreviewProps,
type ProviderPreviewValidation,
} from '@gitroom/frontend/components/provider-preview/preview.provider.component';
type InitPayload = {
value?: Record<string, unknown>;
errors?: string[];
integration?: ProviderPreviewProps['integration'];
};
declare global {
interface Window {
__PROVIDER_INIT__?: InitPayload;
__getProviderPreviewValues__?: () => Record<string, unknown>;
__validateProviderPreview__?: () => Promise<ProviderPreviewValidation>;
}
}
export const ProviderPreviewBridge: FC<{ provider: string }> = ({
provider,
}) => {
const [init] = useState<InitPayload>(() =>
typeof window !== 'undefined' ? window.__PROVIDER_INIT__ ?? {} : {},
);
const controlRef = useRef<ProviderPreviewHandle | null>(null);
useEffect(() => {
window.__getProviderPreviewValues__ = () =>
controlRef.current?.getValues() ?? {};
window.__validateProviderPreview__ = async () =>
controlRef.current
? await controlRef.current.validate()
: { isValid: false, value: {}, errors: ['not-ready'] };
return () => {
delete window.__getProviderPreviewValues__;
delete window.__validateProviderPreview__;
};
}, []);
return (
<ProviderPreviewComponent
provider={provider}
value={init.value}
errors={init.errors}
integration={init.integration}
controlRef={controlRef}
/>
);
};

View file

@ -0,0 +1,52 @@
/**
* Provider settings WebView bridge.
*
* URL: /provider/:p (e.g. /provider/tiktok, /provider/instagram)
*
* --- Initial state (native -> WebView, push once) ---
* Before loading the URL, the native side injects a global:
*
* webView.injectJavaScript(`window.__PROVIDER_INIT__ = ${JSON.stringify({
* value: { ...currentSettings }, // optional, shape = provider DTO
* errors: ['...'], // optional, prior validation errors
* integration: { ... }, // optional Partial<Integration>
* })};`);
*
* The bridge reads this once on mount (see ./bridge.tsx).
*
* --- Reading values & validation (native -> WebView, pull on demand) ---
* No messages are posted from the WebView. Instead, native calls these
* globals (they are defined once the bridge's effect has run):
*
* // Returns the current form values, no validation:
* webView.evaluateJavaScript('window.__getProviderPreviewValues__()')
* // => { ...settings }
*
* // Triggers validation and returns isValid + flattened error strings:
* webView.evaluateJavaScript('window.__validateProviderPreview__()')
* // => Promise<{ isValid: boolean, value: {...}, errors: string[] }>
*
* React Native example (RN WebView ref):
* const js = `window.__validateProviderPreview__().then(r =>
* window.ReactNativeWebView.postMessage(JSON.stringify(r)));
* true;`;
* webViewRef.current?.injectJavaScript(js);
*
* Native should wait for page load (onLoadEnd / didFinishNavigation) before
* calling these. If called before the bridge mounts, the validate getter
* returns { isValid: false, errors: ['not-ready'] } and the values getter
* returns {}.
*
* If a different channel is needed, adjust ./bridge.tsx this page is only
* a server wrapper that forwards the `:p` route param.
*/
import { ProviderPreviewBridge } from './bridge';
export default async function Page({
params,
}: {
params: Promise<{ p: string }>;
}) {
const { p } = await params;
return <ProviderPreviewBridge provider={p} />;
}

View file

@ -4,7 +4,7 @@ import { createContext, useContext } from 'react';
import { Integrations } from '@gitroom/frontend/components/launches/calendar.context';
import dayjs from 'dayjs';
import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone';
export const IntegrationContext = createContext<{
export type IntegrationContextType = {
date: dayjs.Dayjs;
integration: Integrations | undefined;
allIntegrations: Integrations[];
@ -16,7 +16,8 @@ export const IntegrationContext = createContext<{
id: string;
}>;
}>;
}>({
};
export const IntegrationContext = createContext<IntegrationContextType>({
integration: undefined,
value: [],
date: newDayjs(),

View file

@ -28,11 +28,8 @@ class Empty {
empty: string;
}
export enum PostComment {
ALL,
POST,
COMMENT,
}
export { PostComment } from '@gitroom/frontend/components/new-launch/providers/post-comment.enum';
import { PostComment } from '@gitroom/frontend/components/new-launch/providers/post-comment.enum';
interface CharacterCondition {
format: 'no-pictures' | 'with-pictures';
@ -72,7 +69,7 @@ export const withProvider = function <T extends object>(params: {
maximumCharacters,
} = params;
return forwardRef((props: { id: string }, ref) => {
const Wrapped = forwardRef((props: { id: string }, ref) => {
const t = useT();
const fetch = useFetch();
const {
@ -356,4 +353,30 @@ export const withProvider = function <T extends object>(params: {
</IntegrationContext.Provider>
);
});
// Expose the settings configuration as static metadata so the preview /
// mobile settings page can render <SettingsComponent /> in isolation
// without pulling the launch store + DOM portals.
(Wrapped as any).__settings = {
SettingsComponent,
CustomPreviewComponent,
dto,
postComment,
maximumCharacters,
};
return Wrapped;
};
/** Pulls the settings metadata off a withProvider-wrapped component. */
export const getProviderSettingsMeta = (component: unknown) => {
return (component as any)?.__settings as
| {
SettingsComponent: FC<{ values?: any }> | null;
CustomPreviewComponent?: FC<{ maximumCharacters?: number }>;
dto?: any;
postComment: PostComment;
maximumCharacters?: number | ((settings: any) => number);
}
| undefined;
};

View file

@ -0,0 +1,5 @@
export enum PostComment {
ALL,
POST,
COMMENT,
}

View file

@ -4,7 +4,7 @@ import { create } from 'zustand';
import dayjs from 'dayjs';
import { Integrations } from '@gitroom/frontend/components/launches/calendar.context';
import { createRef, RefObject } from 'react';
import { PostComment } from '@gitroom/frontend/components/new-launch/providers/high.order.provider';
import { PostComment } from '@gitroom/frontend/components/new-launch/providers/post-comment.enum';
import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone';
interface Values {

View file

@ -0,0 +1,186 @@
'use client';
import 'reflect-metadata';
import { FC, MutableRefObject, useEffect, useMemo } from 'react';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { classValidatorResolver } from '@hookform/resolvers/class-validator';
import { Providers } from '@gitroom/frontend/components/new-launch/providers/show.all.providers';
import { getProviderSettingsMeta } from '@gitroom/frontend/components/new-launch/providers/high.order.provider';
import {
IntegrationContext,
type IntegrationContextType,
} from '@gitroom/frontend/components/launches/helpers/use.integration';
import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone';
type MockIntegration = IntegrationContextType['integration'];
export type ProviderPreviewValidation = {
isValid: boolean;
value: Record<string, unknown>;
errors: string[];
};
export type ProviderPreviewHandle = {
getValues: () => Record<string, unknown>;
validate: () => Promise<ProviderPreviewValidation>;
};
export type ProviderPreviewProps = {
/** Provider identifier (e.g. "tiktok", "instagram", "youtube"). */
provider: string;
/** Initial settings value (shape matches the provider's DTO). */
value?: Record<string, unknown>;
/**
* Called on every form change with the current settings value for the
* mobile WebView bridge this is what you postMessage back.
*/
onChange?: (value: Record<string, unknown>) => void;
/** Validator error messages from a previous failed save, rendered above the form. */
errors?: string[];
/**
* Stub integration to feed the SettingsComponent via IntegrationContext.
* Some providers (e.g. TikTok title) branch on `integration.additionalSettings`
* or `value[0].image` pass what you have, leave the rest to defaults.
*/
integration?: Partial<MockIntegration>;
/**
* Imperative handle populated on mount. The parent calls
* `controlRef.current?.validate()` / `.getValues()` to pull state on demand.
*/
controlRef?: MutableRefObject<ProviderPreviewHandle | null>;
};
const DEFAULT_INTEGRATION: MockIntegration = {
id: 'preview',
name: 'Preview',
identifier: '',
picture: '',
display: '',
type: 'social',
editor: 'normal' as const,
disabled: false,
inBetweenSteps: false,
additionalSettings: '[]',
changeProfilePicture: false,
changeNickName: false,
time: [] as { time: number }[],
};
/** Emits onChange whenever the form changes. Mounted inside FormProvider. */
const FormChangeEmitter: FC<{
onChange?: (value: Record<string, unknown>) => void;
}> = ({ onChange }) => {
const values = useWatch();
useEffect(() => {
if (onChange) onChange(values ?? {});
}, [values, onChange]);
return null;
};
const flattenFormErrors = (errs: unknown): string[] => {
const out: string[] = [];
const walk = (node: unknown) => {
if (!node || typeof node !== 'object') return;
const n = node as Record<string, unknown>;
if (typeof n.message === 'string') out.push(n.message);
if (n.types && typeof n.types === 'object') {
for (const t of Object.values(n.types as Record<string, unknown>)) {
if (typeof t === 'string') out.push(t);
}
}
for (const [key, child] of Object.entries(n)) {
if (['message', 'type', 'types', 'ref', 'root'].includes(key)) continue;
walk(child);
}
};
walk(errs);
return out;
};
export const ProviderPreviewComponent: FC<ProviderPreviewProps> = ({
provider,
value,
onChange,
errors,
integration,
controlRef,
}) => {
const meta = useMemo(() => {
const entry = Providers.find((p) => p.identifier === provider);
if (!entry) return null;
return getProviderSettingsMeta(entry.component);
}, [provider]);
const form = useForm({
resolver: meta?.dto ? classValidatorResolver(meta.dto) : undefined,
defaultValues: value ?? {},
values: value,
mode: 'all',
criteriaMode: 'all',
reValidateMode: 'onChange',
});
useEffect(() => {
if (!controlRef) return;
controlRef.current = {
getValues: () => form.getValues() as Record<string, unknown>,
validate: async () => {
const isValid = await form.trigger(undefined, { shouldFocus: false });
return {
isValid,
value: form.getValues() as Record<string, unknown>,
errors: flattenFormErrors(form.formState.errors),
};
},
};
return () => {
if (controlRef.current) controlRef.current = null;
};
}, [controlRef, form]);
const contextValue = useMemo<IntegrationContextType>(
() => ({
date: newDayjs(),
integration: {
...(DEFAULT_INTEGRATION as MockIntegration),
identifier: provider,
...integration,
} as MockIntegration,
allIntegrations: [],
value: [],
}),
[provider, integration],
);
if (!meta) {
return <div>Provider &quot;{provider}&quot; not found</div>;
}
const { SettingsComponent } = meta;
if (!SettingsComponent) {
return (
<div className="p-4 text-sm">
This provider has no configurable settings.
</div>
);
}
return (
<IntegrationContext.Provider value={contextValue}>
<FormProvider {...form}>
<div className="flex flex-col gap-4 text-white">
{errors && errors.length > 0 && (
<div className="rounded-md border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300">
<ul className="list-disc ps-5">
{errors.map((e, i) => (
<li key={i}>{e}</li>
))}
</ul>
</div>
)}
<FormChangeEmitter onChange={onChange} />
<SettingsComponent />
</div>
</FormProvider>
</IntegrationContext.Provider>
);
};

View file

@ -46,6 +46,7 @@ export async function proxy(request: NextRequest) {
if (
nextUrl.pathname.startsWith('/uploads/') ||
nextUrl.pathname.startsWith('/p/') ||
nextUrl.pathname.startsWith('/provider/') ||
nextUrl.pathname.startsWith('/icons/')
) {
return topResponse;