const { useState, useMemo, useEffect, useRef } = React;
/* ---- small custom glyphs for brand icons lucide doesn't ship ---- */
function TikTokGlyph({ size = 15, color = "currentColor" }) {
return (
);
}
function ThreadsGlyph({ size = 15, color = "currentColor" }) {
return (
);
}
function BlueskyGlyph({ size = 15, color = "currentColor" }) {
return (
);
}
function GBPGlyph({ size = 18, color = "currentColor" }) {
return (
);
}
const PLATFORMS = {
facebook: { label: "Facebook", icon: Facebook, color: "#1877F2", soft: "#E9F1FE", limit: 63206 },
twitter: { label: "Twitter", icon: Twitter, color: "#1DA1F2", soft: "#E8F7FE", limit: 280 },
instagram: { label: "Instagram", icon: Instagram, color: "#C13584", soft: "#FBEAF3", limit: 2200 },
linkedin: { label: "LinkedIn", icon: Linkedin, color: "#0A66C2", soft: "#E8F1FA", limit: 3000 },
youtube: { label: "YouTube", icon: Youtube, color: "#FF0000", soft: "#FFEAEA", limit: 5000 },
pinterest: { label: "Pinterest", icon: Pin, color: "#E60023", soft: "#FDE9EC", limit: 500 },
tiktok: { label: "TikTok", icon: TikTokGlyph, color: "#161823", soft: "#EFEFF2", limit: 2200 },
threads: { label: "Threads", icon: ThreadsGlyph, color: "#161823", soft: "#F1E9FA", limit: 500 },
bluesky: { label: "Bluesky", icon: BlueskyGlyph, color: "#1185FE", soft: "#E7F2FE", limit: 300 },
google: { label: "Google Business", icon: GBPGlyph, color: "#EA9F00", soft: "#FBF1DC", limit: 1500 },
};
/* short blurb for each platform's card on the "works everywhere" showcase */
const PLATFORM_BLURB = {
facebook: "Publish to pages, groups, and personal profiles from one calendar.",
linkedin: "Schedule to company pages and personal profiles.",
twitter: "Post updates and threads, queued for exactly the right moment.",
instagram: "Schedule feed posts, Reels, and Stories from a single queue.",
google: "Keep your local listing fresh with scheduled updates.",
pinterest: "Queue pins across every board without lifting a finger.",
tiktok: "Line up short-form video drops ahead of time.",
youtube: "Schedule uploads and track how each one performs.",
threads: "Publish to Threads right alongside your other channels.",
bluesky: "Schedule posts to Bluesky as the network grows.",
};
/* decorative-only icons for the floating background field (not connectable platforms) */
const FLOATERS = [
{ icon: Twitter, color: "#1DA1F2", top: "10%", left: "6%", dur: 7, delay: 0 },
{ icon: Youtube, color: "#FF0000", top: "34%", left: "4%", dur: 9, delay: 0.6 },
{ icon: Linkedin, color: "#0A66C2", top: "58%", left: "8%", dur: 8, delay: 1.1 },
{ icon: Cloud, color: "#00A4EF", top: "78%", left: "16%", dur: 10, delay: 0.3 },
{ icon: Instagram, color: "#C13584", top: "82%", left: "44%", dur: 8.5, delay: 0.8 },
{ icon: TikTokGlyph, color: "#161823", top: "80%", left: "68%", dur: 7.5, delay: 0.2 },
{ icon: Zap, color: "#FF8A00", top: "84%", left: "90%", dur: 9.5, delay: 1.3 },
{ icon: Pin, color: "#E60023", top: "56%", left: "92%", dur: 8, delay: 0.5 },
{ icon: HardDrive, color: "#34A853", top: "32%", left: "94%", dur: 10.5, delay: 0.9 },
{ icon: Facebook, color: "#1877F2", top: "10%", left: "92%", dur: 7.2, delay: 0.4 },
{ icon: ThreadsGlyph, color: "#161823", top: "12%", left: "70%", dur: 8.8, delay: 1.5 },
{ icon: BlueskyGlyph, color: "#1185FE", top: "8%", left: "40%", dur: 6.5, delay: 0.1 },
];
const INK = "#0F1E17";
const MUTED = "#647065";
const BORDER = "#E1E5DF";
const BG = "#F7F8F5";
const ACCENT = "#17A673";
const ACCENT_DEEP = "#0E8A5E";
const CORAL = "#FF6B4A";
const AMBER = "#C88A00";
const AMBER_SOFT = "#FBF1DC";
const GREEN = "#17A673";
const GREEN_SOFT = "#E4F6EE";
function uid() {
return Math.random().toString(36).slice(2, 9);
}
function formatWhen(iso) {
const d = new Date(iso);
const day = d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
const time = d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
return { day, time };
}
function defaultDateTime(minutesAhead = 60) {
const d = new Date(Date.now() + minutesAhead * 60000);
d.setSeconds(0, 0);
const tzOffset = d.getTimezoneOffset() * 60000;
return new Date(d - tzOffset).toISOString().slice(0, 16);
}
/* ============================= LOGO ============================= */
function RecuraLogo({ size = 28 }) {
return (
);
}
/* ============================= MASCOT ============================= */
/* Recura's own round little mascot — an original character, not any existing brand's. */
function RecuraMascot({ size = 92 }) {
return (
{/* ears */}
{/* head */}
{/* face */}
{/* eyes */}
{/* blush */}
{/* smile */}
{/* waving hand */}
);
}
/* ============================= NAVBAR ============================= */
function Navbar({ view, setView, loggedIn, onLogout, plan }) {
const [scrolled, setScrolled] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8);
window.addEventListener("scroll", onScroll);
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
setView(loggedIn ? "app" : "landing")}
>
Recura
{!loggedIn && (
{["Features", "Integrations", "Made for"].map((item) => (
{item}
{["Scheduling", "Analytics", "Team workflow"].map((sub) => (
{sub}
))}
))}
Pricing
)}
{loggedIn ? (
<>
setView("plans")}
className="primary-btn"
style={{ display: "flex", alignItems: "center", gap: 6, border: "none", background: plan && plan !== "free" ? GREEN_SOFT : ACCENT, color: plan && plan !== "free" ? ACCENT_DEEP : "#fff", borderRadius: 10, padding: "8px 14px", fontSize: 13.5, fontWeight: 700, cursor: "pointer" }}
>
{plan && plan !== "free" ? `${plan.charAt(0).toUpperCase() + plan.slice(1)} plan` : "Upgrade"}
Log out
>
) : (
<>
setView("login")}
className="ghost-btn"
style={{
border: `1.5px solid ${BORDER}`,
background: "#fff",
borderRadius: 10,
padding: "8px 16px",
fontSize: 13.5,
fontWeight: 600,
color: INK,
cursor: "pointer",
}}
>
Log in
setView("login")}
className="primary-btn"
style={{
border: "none",
background: ACCENT,
color: "#fff",
borderRadius: 10,
padding: "9px 18px",
fontSize: 13.5,
fontWeight: 700,
cursor: "pointer",
}}
>
Get started free
setMenuOpen((o) => !o)}
style={{ border: `1.5px solid ${BORDER}`, background: "#fff", borderRadius: 10, width: 38, height: 38, alignItems: "center", justifyContent: "center", cursor: "pointer", color: INK }}
>
{menuOpen ? : ☰ }
>
)}
);
}
/* ============================= LANDING ============================= */
function Landing({ setView }) {
const [email, setEmail] = useState("");
return (
{/* animated gradient blobs */}
{/* floating platform badges */}
{FLOATERS.map((f, i) => {
const Icon = f.icon;
return (
);
})}
Now scheduling to 10 platforms
Social moves faster than your dashboards.
Your social media, on autopilot.
Write a post once, connect every account you run, and let Recura queue it up for the right time — automatically.
Create a full week of social content in 10 minutes. Grow your audience with Recura's Nexa social media AI.
{[
{ icon: Repeat, label: "Recycle Posts", delay: "0s" },
{ icon: LayoutTemplate, label: "Inspiration Templates", delay: "0.4s" },
{ icon: Rocket, label: "Nexa AI Assist", delay: "0.8s" },
].map((f, i) => {
const Icon = f.icon;
return (
{f.label}
);
})}
setEmail(e.target.value)}
placeholder="Enter your email…"
style={{ flex: 1, border: "none", outline: "none", padding: "10px 12px", fontSize: 14, background: "transparent", color: INK }}
/>
setView("login")}
className="primary-btn"
style={{
display: "flex",
alignItems: "center",
gap: 6,
border: "none",
borderRadius: 10,
padding: "10px 18px",
background: ACCENT,
color: "#fff",
fontWeight: 700,
fontSize: 13.5,
whiteSpace: "nowrap",
cursor: "pointer",
}}
>
Get started free
No credit card required. Disconnect anytime.
{/* ---- works-everywhere platform showcase (colorful cards, like the reference) ---- */}
One queue. Every platform.
Connect once and Recura handles the rest, wherever your audience is.
{Object.entries(PLATFORMS).map(([key, p], i) => {
const Icon = p.icon;
return (
{p.label}
{PLATFORM_BLURB[key]}
);
})}
);
}
/* ============================= LOGIN ============================= */
function Login({ setView, onAuthed, startStep }) {
const [mode, setMode] = useState("login");
const [step, setStep] = useState(startStep || "credentials"); // credentials | phone | otp
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [phone, setPhone] = useState("");
const [otp, setOtp] = useState(["", "", "", "", "", ""]);
const [error, setError] = useState(false);
const [errorMsg, setErrorMsg] = useState("");
const [devCode, setDevCode] = useState(null); // only ever set when the server runs OTP_PROVIDER=dev
const [resendIn, setResendIn] = useState(0);
const [busy, setBusy] = useState(false);
const otpRefs = useRef([]);
useEffect(() => {
if (resendIn <= 0) return;
const t = setInterval(() => setResendIn((s) => Math.max(0, s - 1)), 1000);
return () => clearInterval(t);
}, [resendIn]);
const shake = () => {
setError(true);
setTimeout(() => setError(false), 400);
};
const submitCredentials = async () => {
if (!email.trim() || !password.trim()) return shake();
setBusy(true);
try {
// Try logging in first; if there's no account yet, register instead.
let u;
try {
u = await Api.login(email.trim(), password);
} catch (loginErr) {
u = await Api.register(email.trim(), password);
}
setErrorMsg("");
onAuthed && onAuthed();
if (u && u.phone_verified) setView("connect");
else setStep("phone");
} catch (e) {
setErrorMsg(e.message || "Something went wrong");
shake();
} finally {
setBusy(false);
}
};
const sendCode = async () => {
const digits = phone.replace(/\D/g, "");
if (digits.length < 10) {
setErrorMsg("Enter your 10-digit mobile number.");
return shake();
}
setBusy(true);
try {
const res = await Api.sendOtp(phone);
setDevCode(res.dev_code || null);
setErrorMsg("");
setOtp(["", "", "", "", "", ""]);
setResendIn(30);
setStep("otp");
setTimeout(() => otpRefs.current[0]?.focus(), 50);
} catch (e) {
setErrorMsg(e.message || "Couldn't send the code.");
shake();
} finally {
setBusy(false);
}
};
const updateOtpDigit = (i, val) => {
const digit = val.replace(/\D/g, "").slice(-1);
setOtp((o) => {
const next = [...o];
next[i] = digit;
return next;
});
if (digit && i < 5) otpRefs.current[i + 1]?.focus();
};
const onOtpKeyDown = (i, e) => {
if (e.key === "Backspace" && !otp[i] && i > 0) otpRefs.current[i - 1]?.focus();
};
const verifyOtp = async () => {
const entered = otp.join("");
if (entered.length < 6) {
setErrorMsg("Enter all 6 digits.");
return shake();
}
setBusy(true);
try {
await Api.verifyOtp(phone, entered);
setErrorMsg("");
onAuthed && onAuthed();
setView("connect");
} catch (e) {
setErrorMsg(e.message || "Wrong code.");
setOtp(["", "", "", "", "", ""]);
setTimeout(() => otpRefs.current[0]?.focus(), 50);
shake();
} finally {
setBusy(false);
}
};
return (
Recura
{/* step indicator */}
{["credentials", "phone", "otp"].map((s, i) => (
))}
{step === "credentials" && (
<>
{mode === "login" ? "Welcome back" : "Create your account"}
{mode === "login" ? "Log in to manage your queue." : "Start scheduling in under a minute."}
setEmail(e.target.value)}
placeholder="you@example.com"
style={{ border: "none", outline: "none", flex: 1, fontSize: 13.5, background: "transparent", color: INK }}
/>
setPassword(e.target.value)}
placeholder="••••••••"
style={{ border: "none", outline: "none", flex: 1, fontSize: 13.5, background: "transparent", color: INK }}
/>
{mode === "login" ? "Continue" : "Continue"}
{["G", "in", ""].map((letter, i) => (
{i === 2 ? : letter}
))}
{mode === "login" ? "New to Recura?" : "Already have an account?"}{" "}
setMode(mode === "login" ? "signup" : "login")} style={{ color: ACCENT, fontWeight: 600, cursor: "pointer" }}>
{mode === "login" ? "Sign up" : "Log in"}
>
)}
{step === "phone" && (
<>
Verify your phone
We'll text a 6-digit code to confirm it's you.
{errorMsg &&
{errorMsg}
}
{busy ? "Sending…" : "Send code"}
setStep("credentials")} style={{ textAlign: "center", fontSize: 12.5, color: MUTED, cursor: "pointer", margin: 0 }}>
← Back
>
)}
{step === "otp" && (
<>
Enter the code
Sent to {phone || "your phone"}
{devCode ? (
Test mode (no SMS sent) — your code is {devCode}
) : (
)}
{otp.map((d, i) => (
(otpRefs.current[i] = el)}
value={d}
onChange={(e) => updateOtpDigit(i, e.target.value)}
onKeyDown={(e) => onOtpKeyDown(i, e)}
maxLength={1}
inputMode="numeric"
className="auth-input"
style={{
flex: "1 1 0",
minWidth: 0,
maxWidth: 42,
height: 50,
textAlign: "center",
fontSize: 19,
fontWeight: 700,
borderRadius: 10,
border: `1.5px solid ${BORDER}`,
outline: "none",
color: INK,
fontFamily: "'JetBrains Mono', monospace",
}}
/>
))}
{errorMsg &&
{errorMsg}
}
{busy ? "Verifying…" : "Verify & continue"}
{resendIn > 0 ? (
<>Resend code in {resendIn}s>
) : (
Resend code
)}
{" · "}
setStep("phone")} style={{ color: MUTED, cursor: "pointer" }}>
Change number
>
)}
);
}
/* ============================= CONNECT ACCOUNTS ============================= */
function Connect({ connected, setConnected, setView, accounts, refreshAccounts }) {
const [authModal, setAuthModal] = useState(null); // platform key pending authorization
const [authing, setAuthing] = useState(false);
const [blueskyHandle, setBlueskyHandle] = useState("");
const [blueskyPw, setBlueskyPw] = useState("");
const [connectError, setConnectError] = useState("");
const accountIdFor = (key) => {
const a = accounts.find((a) => a.platform === key);
return a ? a.id : null;
};
const disconnect = async (key) => {
const id = accountIdFor(key);
if (id) {
try {
await Api.disconnectAccount(id);
} catch (e) {
/* ignore, still refresh below */
}
}
await refreshAccounts();
};
const requestConnect = (key) => {
setConnectError("");
setAuthModal(key);
};
const confirmAuthorize = async () => {
if (authModal === "bluesky") {
if (!blueskyHandle.trim() || !blueskyPw.trim()) {
setConnectError("Enter your handle and an app password.");
return;
}
setAuthing(true);
try {
await Api.connectBluesky(blueskyHandle.trim(), blueskyPw.trim());
await refreshAccounts();
setAuthing(false);
setAuthModal(null);
setBlueskyHandle("");
setBlueskyPw("");
} catch (e) {
setAuthing(false);
setConnectError(e.message || "Couldn't connect Bluesky");
}
return;
}
// Every other platform is real OAuth: leave the app and let the backend
// + the platform's own login screen take over. It redirects back here
// (?connected=platform&status=ok|error) when done.
window.location.href = Api.oauthStartUrl(authModal);
};
return (
Connect your accounts
Pick every platform you post to. You can add or remove accounts anytime.
{/* platform grid */}
{Object.entries(PLATFORMS).map(([key, p], i) => {
const Icon = p.icon;
const isConnected = connected.includes(key);
return (
{p.label}
(isConnected ? disconnect(key) : requestConnect(key))}
className={isConnected ? "ghost-btn" : "primary-btn"}
style={{
width: "100%",
border: isConnected ? `1.5px solid ${BORDER}` : "none",
borderRadius: 9,
padding: "8px 0",
background: isConnected ? "#fff" : ACCENT,
color: isConnected ? INK : "#fff",
fontWeight: 700,
fontSize: 13,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 6,
}}
>
{isConnected ? (
<>
Connected
>
) : (
<>
Connect
>
)}
);
})}
{/* vertical connected rail */}
Connected
{connected.length === 0 ? "Nothing connected yet." : `${connected.length} account${connected.length === 1 ? "" : "s"} linked`}
{connected.length === 0 ? (
Tap "Connect" on any platform to add it here.
) : (
{connected.map((key, i) => {
const p = PLATFORMS[key];
const Icon = p.icon;
return (
{p.label}
disconnect(key)}
style={{ marginLeft: "auto", border: "none", background: "none", color: MUTED, cursor: "pointer", display: "flex" }}
title="Disconnect"
>
);
})}
)}
setView("app")}
disabled={connected.length === 0}
className="primary-btn"
style={{
width: "100%",
marginTop: 14,
border: "none",
borderRadius: 10,
padding: "10px 0",
background: connected.length ? ACCENT : "#D9E4DF",
color: "#fff",
fontWeight: 700,
fontSize: 13.5,
cursor: connected.length ? "pointer" : "not-allowed",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 6,
}}
>
Continue to dashboard
{authModal && (
!authing && setAuthModal(null)}
>
e.stopPropagation()}
style={{ background: "#fff", borderRadius: 18, padding: 28, width: "100%", maxWidth: 360, boxShadow: "0 24px 60px rgba(15,30,23,0.25)", textAlign: "center" }}
>
{(() => {
const Icon = PLATFORMS[authModal].icon;
return ;
})()}
Connect {PLATFORMS[authModal].label}
{authModal === "bluesky"
? "Bluesky doesn't use app-store style login — paste your handle and an app password (create one in the Bluesky app under Settings > App Passwords)."
: "You'll be taken to " + PLATFORMS[authModal].label + " to sign in and approve access. Recura never sees your password."}
{authModal === "bluesky" && !authing && (
setBlueskyHandle(e.target.value)}
placeholder="you.bsky.social"
style={{ border: `1.5px solid ${BORDER}`, borderRadius: 10, padding: "10px 12px", fontSize: 13.5, outline: "none" }}
/>
setBlueskyPw(e.target.value)}
type="password"
placeholder="xxxx-xxxx-xxxx-xxxx (app password)"
style={{ border: `1.5px solid ${BORDER}`, borderRadius: 10, padding: "10px 12px", fontSize: 13.5, outline: "none" }}
/>
)}
{connectError && (
{connectError}
)}
{authing ? (
Authorizing…
) : (
setAuthModal(null)}
className="ghost-btn"
style={{ flex: 1, border: `1.5px solid ${BORDER}`, background: "#fff", borderRadius: 10, padding: "10px 0", fontWeight: 700, fontSize: 13.5, color: INK, cursor: "pointer" }}
>
Cancel
Authorize
)}
)}
);
}
/* ============================= COMPOSER BOX ============================= */
function ComposerBox({ text, setText, overLimit, tightestLimit, selected, image, setImage }) {
const [focused, setFocused] = useState(false);
const fileInputRef = useRef(null);
const pct = tightestLimit ? Math.min(100, (text.length / tightestLimit) * 100) : 0;
const barColor = overLimit ? "#D64545" : pct > 80 ? AMBER : ACCENT;
const onPickFile = (e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => setImage({ name: file.name, url: reader.result, file });
reader.readAsDataURL(file);
e.target.value = "";
};
return (
);
}
/* ============================= AI STUDIO ============================= */
const REWRITE_MODES = [
{ key: "improve", label: "Improve writing" },
{ key: "shorten", label: "Long → short" },
{ key: "expand", label: "Short → long" },
{ key: "professional", label: "Casual → professional" },
{ key: "casual", label: "Professional → casual" },
{ key: "grammar", label: "Fix grammar" },
];
function AiStudioPanel({ text, setText, platforms, notify }) {
const [open, setOpen] = useState(false);
const [tab, setTab] = useState("caption");
const [loading, setLoading] = useState(null); // key of the in-flight action, or null
const [prevText, setPrevText] = useState(null); // one-step undo buffer
const [topic, setTopic] = useState("");
const [tone, setTone] = useState("friendly");
const [language, setLanguage] = useState("Spanish");
const [bestTime, setBestTime] = useState(null);
const primaryPlatform = platforms[0] || null;
const applyText = (next) => {
setPrevText(text);
setText(next);
};
const undo = () => {
if (prevText === null) return;
setText(prevText);
setPrevText(null);
};
const run = async (key, fn) => {
setLoading(key);
try {
await fn();
} catch (e) {
notify(e.message || "AI Studio request failed");
} finally {
setLoading(null);
}
};
const doCaption = () =>
run("caption", async () => {
if (!topic.trim()) return notify("Describe what the post is about first");
const { text: out } = await Api.aiCaption(topic.trim(), primaryPlatform, tone);
applyText(out);
notify("Caption generated");
});
const doHashtags = () =>
run("hashtags", async () => {
if (!text.trim()) return notify("Write (or generate) a caption first");
const { text: out } = await Api.aiHashtags(text, 8);
applyText(`${text}\n\n${out}`);
notify("Hashtags added");
});
const doRewrite = (mode, label) =>
run(mode, async () => {
if (!text.trim()) return notify("There's no text to rewrite yet");
const { text: out } = await Api.aiRewrite(text, mode);
applyText(out);
notify(label);
});
const doTranslate = () =>
run("translate", async () => {
if (!text.trim()) return notify("There's no text to translate yet");
if (!language.trim()) return notify("Pick a language first");
const { text: out } = await Api.aiTranslate(text, language.trim());
applyText(out);
notify(`Translated to ${language.trim()}`);
});
const doBestTime = () =>
run("best-time", async () => {
const { suggestion } = await Api.aiBestTime(primaryPlatform || "general");
setBestTime(suggestion);
});
const TABS = [
{ key: "caption", label: "Caption", icon: Wand2 },
{ key: "hashtags", label: "Hashtags", icon: Hash },
{ key: "rewrite", label: "Rewrite", icon: RefreshCw },
{ key: "translate", label: "Translate", icon: Languages },
{ key: "time", label: "Best time", icon: Clock3 },
];
const smallInputStyle = {
width: "100%",
border: `1px solid ${BORDER}`,
borderRadius: 9,
padding: "8px 11px",
fontSize: 13,
color: INK,
outline: "none",
background: "#fff",
};
const aiBtnStyle = (active) => ({
display: "flex",
alignItems: "center",
gap: 6,
border: `1.5px solid ${active ? ACCENT : BORDER}`,
background: active ? "#EAF6EF" : "#fff",
color: active ? ACCENT : INK,
borderRadius: 8,
padding: "7px 12px",
fontSize: 12.5,
fontWeight: 600,
cursor: "pointer",
});
return (
setOpen((o) => !o)}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 8,
background: "none",
border: "none",
padding: "10px 14px",
cursor: "pointer",
fontSize: 12.5,
fontWeight: 700,
color: INK,
}}
>
AI Studio
caption · hashtags · rewrite · translate
{open && (
{TABS.map((t) => {
const TIcon = t.icon;
return (
setTab(t.key)} style={aiBtnStyle(tab === t.key)}>
{t.label}
);
})}
{prevText !== null && (
Undo
)}
{tab === "caption" && (
)}
{tab === "hashtags" && (
Generates hashtags from whatever's currently in the composer, and appends them.
{loading === "hashtags" ? : } Generate hashtags
)}
{tab === "rewrite" && (
{REWRITE_MODES.map((m) => (
doRewrite(m.key, m.label)} disabled={loading === m.key} style={aiBtnStyle(false)}>
{loading === m.key ? : } {m.label}
))}
)}
{tab === "translate" && (
setLanguage(e.target.value)} placeholder="Language, e.g. French" style={{ ...smallInputStyle, flex: 1 }} />
{loading === "translate" ? : } Translate
)}
{tab === "time" && (
{loading === "best-time" ? : }
{primaryPlatform ? `Best time for ${PLATFORMS[primaryPlatform]?.label || primaryPlatform}` : "Best time to post"}
{bestTime &&
{bestTime}
}
)}
)}
);
}
/* ============================= CHARACTER LIMITS PANEL ============================= */
function CharacterLimitsPanel() {
const [open, setOpen] = useState(false);
return (
setOpen((o) => !o)}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 8,
background: "none",
border: "none",
padding: "10px 14px",
cursor: "pointer",
fontSize: 12.5,
fontWeight: 700,
color: INK,
}}
>
Character limits by platform
{open && (
{Object.entries(PLATFORMS).map(([key, p]) => {
const Icon = p.icon;
return (
{p.label}
{p.limit.toLocaleString()}
);
})}
)}
);
}
/* ============================= VERTICAL PLATFORM RAIL ============================= */
function VerticalPlatformRail({ availableKeys, selected, togglePlatform, connected }) {
return (
Channels
{availableKeys.map((key, i) => {
const p = PLATFORMS[key];
const Icon = p.icon;
const active = selected.includes(key);
const isConnected = connected.length === 0 || connected.includes(key);
return (
togglePlatform(key)}
title={p.label}
style={{
position: "relative",
width: 42,
height: 42,
borderRadius: 12,
border: `1.5px solid ${active ? p.color : BORDER}`,
background: active ? p.soft : "#fff",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
animationDelay: `${0.04 * i}s`,
}}
>
{isConnected && (
)}
);
})}
);
}
/* ============================= MESSAGE CENTER ============================= */
const MOCK_MESSAGES = [
{ id: "m1", platform: "instagram", name: "Priya Sharma", preview: "Love this collection! Do you ship to Pune?", time: "2m", unread: true },
{ id: "m2", platform: "facebook", name: "Alex Turner", preview: "What time does the livestream start today?", time: "18m", unread: true },
{ id: "m3", platform: "twitter", name: "Dev Patel", preview: "Great thread — sharing this with my team.", time: "1h", unread: false },
{ id: "m4", platform: "linkedin", name: "Meera Nair", preview: "Would love to connect about a partnership.", time: "3h", unread: true },
{ id: "m5", platform: "tiktok", name: "@rico.codes", preview: "This edit is so clean 🔥", time: "5h", unread: false },
];
function MessageCenter() {
const [messages, setMessages] = useState(MOCK_MESSAGES);
const unreadCount = messages.filter((m) => m.unread).length;
const markRead = (id) => setMessages((m) => m.map((msg) => (msg.id === id ? { ...msg, unread: false } : msg)));
return (
Messages
{unreadCount > 0 && (
{unreadCount} new
)}
Across all connected accounts
{messages.map((m, i) => {
const p = PLATFORMS[m.platform];
const Icon = p.icon;
return (
markRead(m.id)}
className="queue-item"
style={{
display: "flex",
gap: 12,
alignItems: "flex-start",
padding: "12px 6px",
borderBottom: `1px solid #F1F1EC`,
cursor: "pointer",
animationDelay: `${i * 0.05}s`,
background: m.unread ? "#FAFBF8" : "transparent",
borderRadius: 8,
}}
>
{m.name.slice(0, 1)}
{m.name}
{m.time}
{m.preview}
{m.unread &&
}
);
})}
);
}
/* ============================= APP (composer + queue) ============================= */
function AppDashboard({ connected }) {
const availableKeys = connected.length ? connected : Object.keys(PLATFORMS);
const [selected, setSelected] = useState(availableKeys.slice(0, 2));
const [text, setText] = useState("");
const [image, setImage] = useState(null); // File object, or null
const [when, setWhen] = useState(defaultDateTime());
const [toast, setToast] = useState(null);
const [pulseButton, setPulseButton] = useState(false);
const toastTimer = useRef(null);
const [queue, setQueue] = useState([]);
const [loadingQueue, setLoadingQueue] = useState(true);
const loadPosts = async () => {
try {
const posts = await Api.listPosts();
setQueue(
posts.map((p) => ({
id: p.id,
text: p.text,
platforms: p.platforms,
when: p.scheduled_for,
status: p.status === "posted" ? "posted" : p.status === "failed" ? "failed" : "queued",
image: p.image_path ? `${Api.base}/api/posts/uploads/${p.image_path}` : null,
results: p.results,
}))
);
} catch (e) {
// not logged in / backend unreachable — leave queue empty
} finally {
setLoadingQueue(false);
}
};
useEffect(() => {
loadPosts();
// poll so posts flip from "queued" to "posted"/"failed" as the backend
// scheduler fires them, without needing a manual refresh
const t = setInterval(loadPosts, 15000);
return () => clearInterval(t);
}, []);
const showToast = (msg) => {
clearTimeout(toastTimer.current);
setToast({ id: uid(), msg });
toastTimer.current = setTimeout(() => setToast(null), 2600);
};
useEffect(() => () => clearTimeout(toastTimer.current), []);
const togglePlatform = (key) => setSelected((s) => (s.includes(key) ? s.filter((k) => k !== key) : [...s, key]));
const tightestLimit = useMemo(() => (selected.length === 0 ? null : Math.min(...selected.map((k) => PLATFORMS[k].limit))), [selected]);
const overLimit = tightestLimit !== null && text.length > tightestLimit;
const canSchedule = text.trim().length > 0 && selected.length > 0 && !overLimit;
const sortedQueue = useMemo(() => [...queue].sort((a, b) => new Date(a.when) - new Date(b.when)), [queue]);
const postedCount = useMemo(() => queue.filter((p) => p.status === "posted").length, [queue]);
const fireButtonPulse = () => {
setPulseButton(true);
setTimeout(() => setPulseButton(false), 500);
};
const addToQueue = async () => {
if (!canSchedule) return;
try {
await Api.createPost({ text: text.trim(), platforms: selected, scheduledForIso: when, imageFile: image?.file });
setText("");
setImage(null);
setWhen(defaultDateTime());
fireButtonPulse();
showToast("Added to queue");
loadPosts();
} catch (e) {
showToast(e.message || "Couldn't schedule post");
}
};
const postNow = async () => {
if (!canSchedule) return;
try {
// "Post now" = schedule for a moment in the past so the backend
// scheduler picks it up on its very next 30-second sweep.
const nowIso = new Date(Date.now() - 60000).toISOString().slice(0, 16);
await Api.createPost({ text: text.trim(), platforms: selected, scheduledForIso: nowIso, imageFile: image?.file });
setText("");
setImage(null);
fireButtonPulse();
showToast("Publishing now…");
loadPosts();
setTimeout(loadPosts, 4000);
} catch (e) {
showToast(e.message || "Couldn't publish post");
}
};
const removeFromQueue = async (id) => {
setQueue((q) => q.filter((p) => p.id !== id));
try {
await Api.deletePost(id);
} catch (e) {
loadPosts();
}
};
const grouped = useMemo(() => {
const map = new Map();
for (const post of sortedQueue) {
const { day } = formatWhen(post.when);
if (!map.has(day)) map.set(day, []);
map.get(day).push(post);
}
return Array.from(map.entries());
}, [sortedQueue]);
const stats = [
{ label: "Accounts connected", value: connected.length, icon: Users, color: ACCENT },
{ label: "Posts queued", value: sortedQueue.length - postedCount, icon: Clock3, color: AMBER },
{ label: "Published", value: postedCount, icon: CircleCheck, color: GREEN },
{ label: "Platforms available", value: Object.keys(PLATFORMS).length, icon: BarChart3, color: "#5B5BD6" },
];
return (
Dashboard
{connected.length} account{connected.length === 1 ? "" : "s"} connected
{sortedQueue.length} post{sortedQueue.length === 1 ? "" : "s"} total
{/* stat cards */}
{stats.map((s, i) => {
const Icon = s.icon;
return (
);
})}
New post
Write once, choose where it lands, pick a time.
{availableKeys.map((key, i) => {
const p = PLATFORMS[key];
const Icon = p.icon;
const active = selected.includes(key);
return (
togglePlatform(key)}
style={{
display: "flex",
alignItems: "center",
gap: 7,
padding: "8px 14px",
borderRadius: 999,
border: `1.5px solid ${active ? p.color : BORDER}`,
background: active ? p.soft : "#fff",
color: active ? p.color : MUTED,
fontSize: 13.5,
fontWeight: 600,
cursor: "pointer",
animationDelay: `${0.04 * i}s`,
}}
>
{p.label}
);
})}
{overLimit && (
Too long for a selected platform. Trim it or unselect that platform.
)}
Queue
{sortedQueue.length === 0 && (
Nothing queued yet — schedule your first post to see it here.
)}
{grouped.map(([day, posts]) => (
{day}
{posts.map((post, idx) => {
const { time } = formatWhen(post.when);
const isPosted = post.status === "posted";
return (
{time}
{isPosted ? : }
{isPosted ? "Posted" : "Queued"}
{post.platforms.map((k) => {
const Icon = PLATFORMS[k].icon;
return (
);
})}
removeFromQueue(post.id)}
title="Remove from queue"
style={{ border: "none", background: "none", color: MUTED, cursor: "pointer", padding: 2, display: "inline-flex", transition: "transform 150ms ease, color 150ms ease" }}
onMouseEnter={(e) => (e.currentTarget.style.color = "#D64545")}
onMouseLeave={(e) => (e.currentTarget.style.color = MUTED)}
>
{post.image && (
)}
{post.text}
);
})}
))}
{toast && (
{toast.msg}
)}
);
}
/* ============================= PLANS / RAZORPAY ============================= */
function Plans({ setView, user, refreshUser }) {
const [data, setData] = useState(null);
const [busyPlan, setBusyPlan] = useState("");
const [msg, setMsg] = useState({ type: "", text: "" });
useEffect(() => {
Api.plans().then(setData).catch((e) => setMsg({ type: "err", text: e.message }));
}, []);
const rupees = (paise) => "₹" + (paise / 100).toLocaleString("en-IN");
const pay = async (planId) => {
setMsg({ type: "", text: "" });
setBusyPlan(planId);
try {
const o = await Api.createOrder(planId);
if (!window.Razorpay) throw new Error("Razorpay couldn't load. Check your internet connection and reload.");
const rzp = new window.Razorpay({
key: o.key_id,
amount: o.amount,
currency: o.currency,
order_id: o.order_id,
name: "Recura",
description: `${o.plan_name} plan — 30 days`,
prefill: { email: user?.email || "", contact: user?.phone ? "+" + user.phone : "" },
theme: { color: ACCENT },
handler: async (resp) => {
try {
await Api.verifyPayment(resp);
await refreshUser();
const fresh = await Api.plans();
setData(fresh);
setMsg({ type: "ok", text: "Payment successful — your plan is active. 🎉" });
} catch (e) {
setMsg({ type: "err", text: "Payment received but verification failed: " + e.message + " If money was deducted, it will activate automatically within a minute." });
} finally {
setBusyPlan("");
}
},
modal: { ondismiss: () => setBusyPlan("") },
});
rzp.on("payment.failed", (r) => {
setMsg({ type: "err", text: (r.error && r.error.description) || "Payment failed. You have not been charged." });
setBusyPlan("");
});
rzp.open();
} catch (e) {
setMsg({ type: "err", text: e.message });
setBusyPlan("");
}
};
const current = data?.current?.plan || "free";
const expiry = data?.current?.expires_at ? new Date(data.current.expires_at).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }) : null;
return (
Choose your plan
{current !== "free" && expiry ? `You're on the ${current} plan until ${expiry}. Buying again extends it.` : "Upgrade anytime. Pay securely with UPI, cards or netbanking."}
{msg.text && (
{msg.text}
)}
{data && !data.razorpay_configured && (
Payments aren't switched on yet — add your Razorpay keys to backend/.env.
)}
{(data?.plans || []).map((p, i) => {
const isCurrent = current === p.id;
return (
{p.name}
{rupees(p.amount)}
/ {p.days} days
{p.features.map((f) => (
{f}
))}
pay(p.id)}
disabled={!!busyPlan || !data.razorpay_configured}
className="primary-btn"
style={{ border: "none", borderRadius: 10, padding: "12px 0", background: data.razorpay_configured ? ACCENT : "#D9E4DF", color: "#fff", fontWeight: 700, fontSize: 14, cursor: busyPlan || !data.razorpay_configured ? "not-allowed" : "pointer" }}
>
{busyPlan === p.id ? "Opening payment…" : isCurrent ? "Extend 30 days" : `Pay ${rupees(p.amount)}`}
);
})}
Payments are processed by Razorpay. We never see or store your card or UPI details.
setView("app")} style={{ textAlign: "center", fontSize: 13, color: MUTED, cursor: "pointer", marginTop: 8 }}>← Back to dashboard
);
}
/* ============================= ROOT APP ============================= */
function App() {
const [view, setView] = useState("landing"); // landing | login | connect | app
const [connected, setConnected] = useState([]);
const [accounts, setAccounts] = useState([]);
const [bootError, setBootError] = useState("");
const [user, setUser] = useState(null);
const [startStep, setStartStep] = useState("credentials");
const loggedIn = view === "connect" || view === "app" || view === "plans";
const refreshUser = async () => {
try {
const res = await Api.me();
setUser(res.user);
} catch (e) {}
};
const refreshAccounts = async () => {
try {
const list = await Api.listAccounts();
setAccounts(list);
setConnected([...new Set(list.map((a) => a.platform))]);
} catch (e) {
// not logged in, or backend unreachable
}
};
// On first load: figure out if we're already logged in (existing session
// cookie), and if we just got bounced back here from an OAuth callback.
useEffect(() => {
(async () => {
const params = new URLSearchParams(window.location.search);
const justConnected = params.get("connected");
const status = params.get("status");
let me = null;
try {
const res = await Api.me();
me = res.user;
} catch (e) {
setBootError("Can't reach the Recura backend at " + Api.base + ". Is it running?");
}
if (me) {
setUser(me);
await refreshAccounts();
if (!me.phone_verified) {
// signed in earlier but never finished phone verification
setStartStep("phone");
setView("login");
} else if (justConnected) {
setView("connect");
if (status !== "ok") {
setBootError(`Couldn't connect ${justConnected}${params.get("detail") ? ": " + params.get("detail") : ""}.`);
}
} else {
setView("connect");
}
}
if (justConnected) {
// clean the OAuth-callback params out of the URL
window.history.replaceState({}, "", window.location.pathname);
}
})();
}, []);
return (
{
try {
await Api.logout();
} catch (e) {}
setConnected([]);
setAccounts([]);
setUser(null);
setStartStep("credentials");
setView("landing");
}}
/>
{bootError && (
{bootError}
)}
{view === "landing" && }
{view === "login" && (
{
refreshAccounts();
refreshUser();
}}
/>
)}
{view === "connect" && (
)}
{view === "app" && }
{view === "plans" && }
);
}
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render( );