diff --git a/apps/frontend/src/app/(provider)/layout.tsx b/apps/frontend/src/app/(provider)/layout.tsx
new file mode 100644
index 00000000..f711e7de
--- /dev/null
+++ b/apps/frontend/src/app/(provider)/layout.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
diff --git a/apps/frontend/src/app/(provider)/provider/[p]/bridge.tsx b/apps/frontend/src/app/(provider)/provider/[p]/bridge.tsx
new file mode 100644
index 00000000..8b713bb9
--- /dev/null
+++ b/apps/frontend/src/app/(provider)/provider/[p]/bridge.tsx
@@ -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;
+ errors?: string[];
+ integration?: ProviderPreviewProps['integration'];
+};
+
+declare global {
+ interface Window {
+ __PROVIDER_INIT__?: InitPayload;
+ __getProviderPreviewValues__?: () => Record;
+ __validateProviderPreview__?: () => Promise;
+ }
+}
+
+export const ProviderPreviewBridge: FC<{ provider: string }> = ({
+ provider,
+}) => {
+ const [init] = useState(() =>
+ typeof window !== 'undefined' ? window.__PROVIDER_INIT__ ?? {} : {},
+ );
+
+ const controlRef = useRef(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 (
+
+ );
+};
diff --git a/apps/frontend/src/app/(provider)/provider/[p]/page.tsx b/apps/frontend/src/app/(provider)/provider/[p]/page.tsx
new file mode 100644
index 00000000..15a51d7b
--- /dev/null
+++ b/apps/frontend/src/app/(provider)/provider/[p]/page.tsx
@@ -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
+ * })};`);
+ *
+ * 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 ;
+}
diff --git a/apps/frontend/src/components/launches/helpers/use.integration.ts b/apps/frontend/src/components/launches/helpers/use.integration.ts
index 265431f5..ef53c0ac 100644
--- a/apps/frontend/src/components/launches/helpers/use.integration.ts
+++ b/apps/frontend/src/components/launches/helpers/use.integration.ts
@@ -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({
integration: undefined,
value: [],
date: newDayjs(),
diff --git a/apps/frontend/src/components/new-launch/providers/high.order.provider.tsx b/apps/frontend/src/components/new-launch/providers/high.order.provider.tsx
index 4fdbde4e..b66c76b6 100644
--- a/apps/frontend/src/components/new-launch/providers/high.order.provider.tsx
+++ b/apps/frontend/src/components/new-launch/providers/high.order.provider.tsx
@@ -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 (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 (params: {
);
});
+
+ // Expose the settings configuration as static metadata so the preview /
+ // mobile settings page can render 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;
};
diff --git a/apps/frontend/src/components/new-launch/providers/post-comment.enum.ts b/apps/frontend/src/components/new-launch/providers/post-comment.enum.ts
new file mode 100644
index 00000000..bfaee947
--- /dev/null
+++ b/apps/frontend/src/components/new-launch/providers/post-comment.enum.ts
@@ -0,0 +1,5 @@
+export enum PostComment {
+ ALL,
+ POST,
+ COMMENT,
+}
diff --git a/apps/frontend/src/components/new-launch/store.ts b/apps/frontend/src/components/new-launch/store.ts
index f848bc60..2ea620b8 100644
--- a/apps/frontend/src/components/new-launch/store.ts
+++ b/apps/frontend/src/components/new-launch/store.ts
@@ -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 {
diff --git a/apps/frontend/src/components/provider-preview/preview.provider.component.tsx b/apps/frontend/src/components/provider-preview/preview.provider.component.tsx
new file mode 100644
index 00000000..9c84d8ce
--- /dev/null
+++ b/apps/frontend/src/components/provider-preview/preview.provider.component.tsx
@@ -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;
+ errors: string[];
+};
+
+export type ProviderPreviewHandle = {
+ getValues: () => Record;
+ validate: () => Promise;
+};
+
+export type ProviderPreviewProps = {
+ /** Provider identifier (e.g. "tiktok", "instagram", "youtube"). */
+ provider: string;
+ /** Initial settings value (shape matches the provider's DTO). */
+ value?: Record;
+ /**
+ * Called on every form change with the current settings value — for the
+ * mobile WebView bridge this is what you postMessage back.
+ */
+ onChange?: (value: Record) => 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;
+ /**
+ * Imperative handle populated on mount. The parent calls
+ * `controlRef.current?.validate()` / `.getValues()` to pull state on demand.
+ */
+ controlRef?: MutableRefObject;
+};
+
+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) => 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;
+ 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)) {
+ 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 = ({
+ 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,
+ validate: async () => {
+ const isValid = await form.trigger(undefined, { shouldFocus: false });
+ return {
+ isValid,
+ value: form.getValues() as Record,
+ errors: flattenFormErrors(form.formState.errors),
+ };
+ },
+ };
+ return () => {
+ if (controlRef.current) controlRef.current = null;
+ };
+ }, [controlRef, form]);
+
+ const contextValue = useMemo(
+ () => ({
+ date: newDayjs(),
+ integration: {
+ ...(DEFAULT_INTEGRATION as MockIntegration),
+ identifier: provider,
+ ...integration,
+ } as MockIntegration,
+ allIntegrations: [],
+ value: [],
+ }),
+ [provider, integration],
+ );
+
+ if (!meta) {
+ return Provider "{provider}" not found
;
+ }
+
+ const { SettingsComponent } = meta;
+ if (!SettingsComponent) {
+ return (
+
+ This provider has no configurable settings.
+
+ );
+ }
+
+ return (
+
+
+
+ {errors && errors.length > 0 && (
+
+
+ {errors.map((e, i) => (
+ - {e}
+ ))}
+
+
+ )}
+
+
+
+
+
+ );
+};
diff --git a/apps/frontend/src/proxy.ts b/apps/frontend/src/proxy.ts
index da712ea5..360c937e 100644
--- a/apps/frontend/src/proxy.ts
+++ b/apps/frontend/src/proxy.ts
@@ -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;