diff --git a/apps/builder/app/builder/features/pages/page-settings.tsx b/apps/builder/app/builder/features/pages/page-settings.tsx
index 1e95fe9d42cf..859a86701719 100644
--- a/apps/builder/app/builder/features/pages/page-settings.tsx
+++ b/apps/builder/app/builder/features/pages/page-settings.tsx
@@ -340,8 +340,8 @@ const PathField = ({
To make the path dynamic and use it with CMS, you can use
- parameters and other features. CMS features are part of the
- Pro plan.
+ parameters and other features. You can publish to staging
+ for free; upgrade to Pro to publish to custom domains.
- Dynamic routing and redirect are a part of the CMS
- functionality.
+ Dynamic routing and redirect are part of the CMS functionality.
+ You can publish to staging for free; upgrade to Pro to publish
+ to custom domains.
diff --git a/apps/builder/app/builder/features/publish/collapsible-domain-section.stories.tsx b/apps/builder/app/builder/features/publish/collapsible-domain-section.stories.tsx
index 3fbd20bf83e6..cc01ac317127 100644
--- a/apps/builder/app/builder/features/publish/collapsible-domain-section.stories.tsx
+++ b/apps/builder/app/builder/features/publish/collapsible-domain-section.stories.tsx
@@ -382,7 +382,9 @@ export const UpgradeBanners = () => (
Redirect
Custom contact email
- You can delete these features or upgrade.
+
+ You can delete these features or upgrade to publish to custom domains.
+
diff --git a/apps/builder/app/builder/features/publish/publish.tsx b/apps/builder/app/builder/features/publish/publish.tsx
index 892353d27265..4c1be4628761 100644
--- a/apps/builder/app/builder/features/publish/publish.tsx
+++ b/apps/builder/app/builder/features/publish/publish.tsx
@@ -401,19 +401,28 @@ const Publish = ({
timesLeft,
disabled,
refresh,
+ restrictedFeatures,
}: {
project: Project;
timesLeft: number;
disabled: boolean;
refresh: () => Promise;
+ restrictedFeatures: Map<
+ string,
+ | undefined
+ | { awareness?: Awareness; view?: "pageSettings"; info?: ReactNode }
+ >;
}) => {
const { maxDailyPublishesPerUser } = useStore($permissions);
+ const { userPublishCount } = useUserPublishCount();
const [publishError, setPublishError] = useState<
undefined | JSX.Element | string
>();
const [isPublishing, setIsPublishing] = useOptimistic(false);
const buttonRef = useRef(null);
const [hasSelectedDomains, setHasSelectedDomains] = useState(false);
+ const [hasCustomDomainsSelected, setHasCustomDomainsSelected] =
+ useState(false);
const countdown = usePublishCountdown(isPublishing);
useEffect(() => {
@@ -425,8 +434,18 @@ const Publish = ({
const handleFormInput = () => {
const formData = new FormData(form);
- const domainsSelected = formData.getAll(domainToPublishName).length;
- setHasSelectedDomains(domainsSelected > 0);
+ const domainsSelected = formData
+ .getAll(domainToPublishName)
+ .map((domain) => domain.toString());
+
+ setHasSelectedDomains(domainsSelected.length > 0);
+
+ // Check if any custom domains are selected
+ // Custom domains are those that are NOT the staging domain (project.domain)
+ const hasCustom = domainsSelected.some(
+ (domain) => domain !== project.domain
+ );
+ setHasCustomDomainsSelected(hasCustom);
};
const observer = new MutationObserver(() => {
@@ -445,7 +464,7 @@ const Publish = ({
return () => {
observer.disconnect();
};
- }, []);
+ }, [project.domain]);
const handlePublish = async (formData: FormData) => {
setPublishError(undefined);
@@ -582,7 +601,12 @@ const Publish = ({
formAction={handlePublish}
color="positive"
state={showPendingState ? "pending" : undefined}
- disabled={hasSelectedDomains === false || disabled}
+ disabled={
+ hasSelectedDomains === false ||
+ disabled ||
+ (restrictedFeatures.size > 0 && hasCustomDomainsSelected) ||
+ userPublishCount >= maxDailyPublishesPerUser
+ }
>
{countdown !== undefined && countdown > 0
? `Publishing (${countdown}s)`
@@ -789,7 +813,7 @@ const buttonLinkClass = css({
...textVariants.link,
}).toString();
-const UpgradeBanner = () => {
+const UpgradeBanner = ({ hasCustomDomains }: { hasCustomDomains: boolean }) => {
const restrictedFeatures = useStore($restrictedFeatures);
const { canAddDomain } = useCanAddDomain();
const { userPublishCount, maxDailyPublishesPerUser } = useUserPublishCount();
@@ -812,7 +836,9 @@ const UpgradeBanner = () => {
);
}
- if (restrictedFeatures.size > 0) {
+ // Only show Pro feature upgrade banner if custom domains are available
+ // Free tier users can still publish to staging domain with Pro features
+ if (restrictedFeatures.size > 0 && hasCustomDomains) {
return (
{
)
)}
- You can delete these features or upgrade.
+
+ You can delete these features or upgrade to publish to custom domains.
+
domain.status === "ACTIVE" && domain.verified
+ );
+
return (
diff --git a/apps/builder/app/builder/features/settings-panel/settings-panel.tsx b/apps/builder/app/builder/features/settings-panel/settings-panel.tsx
index 9e3705db845a..adf7d7912828 100644
--- a/apps/builder/app/builder/features/settings-panel/settings-panel.tsx
+++ b/apps/builder/app/builder/features/settings-panel/settings-panel.tsx
@@ -45,10 +45,11 @@ export const SettingsPanel = ({
width={rawTheme.spacing[28]}
style={{ aspectRatio: "4.1" }}
/>
- Upgrade for CMS
+ Upgrade for CMS on custom domains
Integrate content from other tools to create blogs, directories, and
- any other structured content.
+ any other structured content. You can preview CMS on staging without
+ upgrading.
diff --git a/apps/builder/app/dashboard/dashboard.test.ts b/apps/builder/app/dashboard/dashboard.test.ts
index bf8f80aa39f3..54a7f98082e6 100644
--- a/apps/builder/app/dashboard/dashboard.test.ts
+++ b/apps/builder/app/dashboard/dashboard.test.ts
@@ -5,38 +5,24 @@ const { getView } = __testing__;
describe("getView", () => {
test("returns 'search' for search path", () => {
- expect(getView("/dashboard/search", true, true)).toBe("search");
- expect(getView("/dashboard/search", false, true)).toBe("search");
+ expect(getView("/dashboard/search", true, false)).toBe("search");
+ expect(getView("/dashboard/search", false, false)).toBe("search");
});
- test("returns 'welcome' when no projects on default workspace", () => {
- expect(getView("/dashboard", false, true)).toBe("welcome");
- expect(getView("/dashboard/templates", false, true)).toBe("welcome");
+ test("returns 'welcome' when no projects and workspace not suspended", () => {
+ expect(getView("/dashboard", false, false)).toBe("welcome");
});
- test("returns 'projects' when no projects on non-default workspace", () => {
- // Non-default workspaces should never show the welcome onboarding page
- expect(getView("/dashboard", false, false)).toBe("projects");
+ test("returns 'projects' when workspace is suspended with no projects", () => {
+ expect(getView("/dashboard", false, true)).toBe("projects");
});
- test("returns 'templates' on non-default workspace with no projects", () => {
- expect(getView("/dashboard/templates", false, false)).toBe("templates");
- });
-
- test("returns 'templates' for templates path with projects", () => {
- expect(getView("/dashboard/templates", true, true)).toBe("templates");
- });
-
- test("returns 'projects' for dashboard root with projects", () => {
+ test("returns 'projects' when there are projects", () => {
+ expect(getView("/dashboard", true, false)).toBe("projects");
expect(getView("/dashboard", true, true)).toBe("projects");
});
test("returns 'projects' for unknown paths with projects", () => {
- expect(getView("/dashboard/unknown", true, true)).toBe("projects");
- });
-
- test("search takes priority over welcome", () => {
- // Even with no projects, search view should show
- expect(getView("/dashboard/search", false, true)).toBe("search");
+ expect(getView("/dashboard/unknown", true, false)).toBe("projects");
});
});
diff --git a/apps/builder/app/dashboard/dashboard.tsx b/apps/builder/app/dashboard/dashboard.tsx
index 02c882f27f63..aae8b8c3d9b9 100644
--- a/apps/builder/app/dashboard/dashboard.tsx
+++ b/apps/builder/app/dashboard/dashboard.tsx
@@ -17,7 +17,7 @@ import {
Grid,
IconButton,
} from "@webstudio-is/design-system";
-import { BodyIcon, ExtensionIcon } from "@webstudio-is/icons";
+import { BodyIcon } from "@webstudio-is/icons";
import {
NavLink,
useLocation,
@@ -33,10 +33,9 @@ import { dashboardPath } from "~/shared/router-utils";
import { CollapsibleSection } from "~/builder/shared/collapsible-section";
import { ProfileMenu } from "./profile-menu";
import { Projects } from "./projects/projects";
-import { Templates } from "./templates/templates";
import { Welcome } from "./welcome/welcome";
import { Header } from "./shared/layout";
-import { help } from "~/shared/help";
+import { help, socialLinks } from "~/shared/help";
import { SearchResults } from "./search/search-results";
import type { DashboardData } from "./shared/types";
import { Search } from "./search/search-field";
@@ -194,22 +193,16 @@ export const DashboardSetup = ({ data }: { data: DashboardData }) => {
const getView = (
pathname: string,
hasProjects: boolean,
- isDefaultWorkspace: boolean
+ isWorkspaceSuspended: boolean
) => {
if (pathname === dashboardPath("search")) {
return "search";
}
- // Only show the onboarding welcome page on the default workspace
- // when the user has no projects yet. Non-default workspaces that are
- // empty should show the normal (empty) projects view.
- if (hasProjects === false && isDefaultWorkspace) {
+ if (hasProjects === false && isWorkspaceSuspended === false) {
return "welcome";
}
- if (pathname === dashboardPath("templates")) {
- return "templates";
- }
return "projects";
};
@@ -229,7 +222,6 @@ export const Dashboard = () => {
publisherHost,
projectToClone,
projects,
- templates,
workspaces,
currentWorkspaceId,
} = data;
@@ -243,38 +235,21 @@ export const Dashboard = () => {
}
const isWorkspaceSuspended = isDowngradedForMember(currentWorkspace);
- const hasProjects = projects.length > 0 || isWorkspaceSuspended;
- const isDefaultWorkspace =
- currentWorkspaceId === undefined ||
- workspaces?.find((w) => w.id === currentWorkspaceId)?.isDefault === true;
- const view = getView(location.pathname, hasProjects, isDefaultWorkspace);
+ const hasProjects = projects.length > 0;
+ const view = getView(location.pathname, hasProjects, isWorkspaceSuspended);
const showWorkspaceSelector =
workspaces !== undefined &&
workspaces.length > 0 &&
currentWorkspaceId !== undefined;
- const navItems =
- view === "welcome"
- ? [
- {
- to: dashboardPath(),
- prefix: ,
- children: "Welcome",
- },
- ]
- : [
- {
- to: dashboardPath("projects"),
- prefix: ,
- children: "Projects",
- },
- {
- to: dashboardPath("templates"),
- prefix: ,
- children: "Starter templates",
- },
- ];
+ const navItems = [
+ {
+ to: dashboardPath("projects"),
+ prefix: ,
+ children: "Projects",
+ },
+ ];
return (
@@ -360,6 +335,29 @@ export const Dashboard = () => {
children: item.label,
}))}
/>
+
+
+ Follow us:
+
+ {socialLinks.map(({ label, url, icon }) => (
+
+ {icon}
+
+ ))}
+
{view === "projects" && (
@@ -372,17 +370,8 @@ export const Dashboard = () => {
isWorkspaceSuspended={isWorkspaceSuspended}
/>
)}
- {view === "templates" && (
-
- )}
{view === "welcome" && (
-
+
)}
{view === "search" && }
diff --git a/apps/builder/app/dashboard/projects/projects.tsx b/apps/builder/app/dashboard/projects/projects.tsx
index 83d2c65b2c85..37025d3a9bd5 100644
--- a/apps/builder/app/dashboard/projects/projects.tsx
+++ b/apps/builder/app/dashboard/projects/projects.tsx
@@ -10,6 +10,8 @@ import {
ToggleGroupButton,
PanelBanner,
panelBannerIconColor,
+ Link,
+ buttonStyle,
} from "@webstudio-is/design-system";
import { RepeatGridIcon, ListViewIcon } from "@webstudio-is/icons";
import type { DashboardProject } from "@webstudio-is/dashboard";
@@ -145,6 +147,15 @@ export const Projects = (props: ProjectsProps) => {
+
+ Use template
+
{permissions.canCreateProject && (
)}
diff --git a/apps/builder/app/dashboard/search/search-results.tsx b/apps/builder/app/dashboard/search/search-results.tsx
index 18ed6b8e3282..11315e61b6a3 100644
--- a/apps/builder/app/dashboard/search/search-results.tsx
+++ b/apps/builder/app/dashboard/search/search-results.tsx
@@ -1,42 +1,37 @@
import { useMemo } from "react";
import { matchSorter } from "match-sorter";
import { useSearchParams } from "react-router-dom";
-import { Flex, Separator, Text, theme } from "@webstudio-is/design-system";
+import { Flex, Text, theme } from "@webstudio-is/design-system";
import type { DashboardProject } from "@webstudio-is/dashboard";
import { ProjectsGrid } from "../projects/projects";
import { Header, Main } from "../shared/layout";
import type { DashboardData } from "../shared/types";
import { NothingFound } from "./nothing-found";
-import { TemplatesGrid } from "../templates/templates";
type SearchResults = {
projects: Array;
- templates: Array;
};
const initialSearchResults: SearchResults = {
- templates: [],
projects: [],
} as const;
export const SearchResults = (props: DashboardData) => {
const [searchParams] = useSearchParams();
- const { projects, templates, publisherHost } = props;
+ const { projects, publisherHost } = props;
const search = searchParams.get("q");
const results = useMemo(() => {
- if (!search || !projects || !templates) {
+ if (!search || !projects) {
return initialSearchResults;
}
const keys = ["title", "domain"];
return {
projects: matchSorter(projects, search, { keys }),
- templates: matchSorter(templates, search, { keys }),
};
- }, [projects, templates, search]);
+ }, [projects, search]);
- const nothingFound =
- results.projects.length === 0 && results.templates.length === 0;
+ const nothingFound = results.projects.length === 0;
return (
@@ -66,15 +61,6 @@ export const SearchResults = (props: DashboardData) => {
/>
>
)}
- {results.templates.length > 0 && (
- <>
-
-
- Templates
-
-
- >
- )}
);
diff --git a/apps/builder/app/dashboard/shared/types.ts b/apps/builder/app/dashboard/shared/types.ts
index 35890fedbb13..7c1e2dfa0992 100644
--- a/apps/builder/app/dashboard/shared/types.ts
+++ b/apps/builder/app/dashboard/shared/types.ts
@@ -7,7 +7,6 @@ import type { Notifications } from "~/shared/polly/types";
export type DashboardData = {
user: User;
projects: Array;
- templates: Array;
planFeatures: PlanFeatures;
purchases: Array;
publisherHost: string;
diff --git a/apps/builder/app/dashboard/templates/template-card.tsx b/apps/builder/app/dashboard/templates/template-card.tsx
deleted file mode 100644
index 5b29310e53ec..000000000000
--- a/apps/builder/app/dashboard/templates/template-card.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import { useState } from "react";
-import { Flex, Text, theme } from "@webstudio-is/design-system";
-import type { DashboardProject } from "@webstudio-is/dashboard";
-import { builderUrl } from "~/shared/router-utils";
-import { Card, CardContent, CardFooter } from "../shared/card";
-import { ThumbnailWithAbbr, ThumbnailWithImage } from "../shared/thumbnail";
-import { CloneProjectDialog } from "~/shared/clone-project";
-
-type TemplateCardProps = {
- project: DashboardProject;
-};
-
-export const TemplateCard = ({ project, ...props }: TemplateCardProps) => {
- const [isDuplicateDialogOpen, setIsDuplicateDialogOpen] = useState(false);
- const { title, previewImageAsset } = project;
- return (
-
-
- {previewImageAsset ? (
- {
- setIsDuplicateDialogOpen(true);
- }}
- />
- ) : (
- {
- setIsDuplicateDialogOpen(true);
- }}
- />
- )}
-
-
-
-
- {title}
-
-
-
- {
- window.location.href = builderUrl({
- origin: window.origin,
- projectId: projectId,
- });
- }}
- />
-
- );
-};
diff --git a/apps/builder/app/dashboard/templates/templates.tsx b/apps/builder/app/dashboard/templates/templates.tsx
deleted file mode 100644
index e51f4114dcfd..000000000000
--- a/apps/builder/app/dashboard/templates/templates.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import {
- Flex,
- Grid,
- List,
- ListItem,
- Text,
- rawTheme,
- theme,
-} from "@webstudio-is/design-system";
-import type { DashboardProject } from "@webstudio-is/dashboard";
-import { Header, Main } from "../shared/layout";
-import { CreateProject } from "../projects/project-dialogs";
-import { useStore } from "@nanostores/react";
-import { TemplateCard } from "./template-card";
-import { $permissions } from "~/shared/nano-states";
-
-export const TemplatesGrid = ({
- projects,
-}: {
- projects: Array;
-}) => {
- return (
-
-
- {projects.map((project) => {
- return (
-
-
-
- );
- })}
-
-
- );
-};
-
-export const Templates = ({
- projects,
- currentWorkspaceId,
-}: {
- projects: Array;
- currentWorkspaceId?: string;
-}) => {
- const permissions = useStore($permissions);
- return (
-
-
-
- Starter templates
-
- {permissions.canCreateProject && (
-
-
-
- )}
-
-
-
-
-
- );
-};
diff --git a/apps/builder/app/dashboard/welcome/welcome.tsx b/apps/builder/app/dashboard/welcome/welcome.tsx
index 8c1da3c0a7cd..0e0e0a861870 100644
--- a/apps/builder/app/dashboard/welcome/welcome.tsx
+++ b/apps/builder/app/dashboard/welcome/welcome.tsx
@@ -1,46 +1,56 @@
-import { Flex, Text, theme } from "@webstudio-is/design-system";
-import type { DashboardProject } from "@webstudio-is/dashboard";
+import { Flex, Text, Link, buttonStyle } from "@webstudio-is/design-system";
import { useStore } from "@nanostores/react";
-import { Header, Main } from "../shared/layout";
+import { Main } from "../shared/layout";
import { CreateProject } from "../projects/project-dialogs";
-import { TemplatesGrid } from "../templates/templates";
import { $permissions } from "~/shared/nano-states";
export const Welcome = ({
- projects,
currentWorkspaceId,
}: {
- projects: Array;
currentWorkspaceId?: string;
}) => {
const permissions = useStore($permissions);
return (
-
-
-
- Welcome to Webstudio!
-
-
-
-
-
- Start with a template
- {permissions.canCreateProject ? " or" : ""}
-
+
+ Welcome!
+
+
+
+
+ Start from a template
+
{permissions.canCreateProject && (
)}
-
+
+
);
diff --git a/apps/builder/app/env/env.server.ts b/apps/builder/app/env/env.server.ts
index 834d7a2eef56..1599cae10f3a 100644
--- a/apps/builder/app/env/env.server.ts
+++ b/apps/builder/app/env/env.server.ts
@@ -44,17 +44,6 @@ const envSchema = z.object({
ENTRI_APPLICATION_ID: z.string().default("webstudio"),
ENTRI_SECRET: z.string().optional(),
- // Projects as templates in dashboard (comma-separated IDs)
- PROJECT_TEMPLATES: z
- .string()
- .default("")
- .transform((val) =>
- val
- .split(",")
- .map((id) => id.trim())
- .filter(Boolean)
- ),
-
PUBLISHER_HOST: z.string().default("wstd.work"),
STAGING_USERNAME: z.string().default("admin"),
@@ -114,7 +103,6 @@ const rawEnv = {
RESIZE_ORIGIN: process.env.RESIZE_ORIGIN,
ENTRI_APPLICATION_ID: process.env.ENTRI_APPLICATION_ID,
ENTRI_SECRET: process.env.ENTRI_SECRET,
- PROJECT_TEMPLATES: process.env.PROJECT_TEMPLATES,
PUBLISHER_HOST: process.env.PUBLISHER_HOST,
STAGING_USERNAME: process.env.STAGING_USERNAME,
STAGING_PASSWORD: process.env.STAGING_PASSWORD,
diff --git a/apps/builder/app/routes/_ui.dashboard.templates.tsx b/apps/builder/app/routes/_ui.dashboard.templates.tsx
deleted file mode 100644
index 28568f1d55a3..000000000000
--- a/apps/builder/app/routes/_ui.dashboard.templates.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { lazy } from "react";
-import { type MetaFunction } from "@remix-run/react";
-import { ClientOnly } from "~/shared/client-only";
-export { ErrorBoundary } from "~/shared/error/error-boundary";
-
-export const meta = () => {
- const metas: ReturnType = [];
-
- metas.push({ title: "Webstudio Dashboard | Templates" });
-
- return metas;
-};
-
-const Dashboard = lazy(async () => {
- const { Dashboard } = await import("~/dashboard/index.client");
- return { default: Dashboard };
-});
-
-const DashboardRoute = () => {
- return (
-
-
-
- );
-};
-
-export default DashboardRoute;
diff --git a/apps/builder/app/routes/_ui.dashboard.tsx b/apps/builder/app/routes/_ui.dashboard.tsx
index a94d71a20200..76ab0be5edc0 100644
--- a/apps/builder/app/routes/_ui.dashboard.tsx
+++ b/apps/builder/app/routes/_ui.dashboard.tsx
@@ -23,7 +23,6 @@ import { notification as notificationApi } from "@webstudio-is/project/index.ser
import type { Role } from "@webstudio-is/project";
import { parseBuilderUrl } from "@webstudio-is/http-client";
import { dashboardProjectRouter } from "@webstudio-is/dashboard/index.server";
-import { db as dashboardDb } from "@webstudio-is/dashboard/index.server";
import { builderUrl, isDashboard, loginPath } from "~/shared/router-utils";
import { getSetting, setSetting } from "~/builder/shared/client-settings";
import env from "~/env/env.server";
@@ -129,12 +128,6 @@ const loadDashboardData = async (request: Request) => {
? []
: await dashboardProjectCaller(context).findMany(findManyInput);
- const templates = await dashboardDb.db.findManyByIds(
- env.PROJECT_TEMPLATES,
- context,
- { skipApprovalCheck: true }
- );
-
const notifications = await notificationApi.list(context);
return {
@@ -144,7 +137,6 @@ const loadDashboardData = async (request: Request) => {
planFeatures,
purchases,
projects,
- templates,
workspaces,
currentWorkspaceId,
role,
@@ -198,7 +190,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
purchases,
origin,
projects,
- templates,
workspaces,
currentWorkspaceId,
role,
@@ -210,7 +201,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
return {
user,
projects,
- templates,
planFeatures,
purchases,
publisherHost: env.PUBLISHER_HOST,
diff --git a/apps/builder/app/shared/copy-paste/plugin-html.test.tsx b/apps/builder/app/shared/copy-paste/plugin-html.test.tsx
index b8fd1ed6bc4c..88c38206cc10 100644
--- a/apps/builder/app/shared/copy-paste/plugin-html.test.tsx
+++ b/apps/builder/app/shared/copy-paste/plugin-html.test.tsx
@@ -63,3 +63,59 @@ test("ignore html without any tags", async () => {
expect(await html.onPaste?.(`It works`)).toEqual(false);
expect($instances.get()).toEqual(data.instances);
});
+
+test("skip whitespace-only text nodes between element siblings", async () => {
+ const data = renderData(
+
+
+
+ );
+ $project.set({ id: "" } as Project);
+ $instances.set(data.instances);
+ $pages.set(
+ createDefaultPages({ rootInstanceId: "bodyId", homePageId: "pageId" })
+ );
+ $awareness.set({ pageId: "pageId", instanceSelector: ["divId", "bodyId"] });
+
+ // Regression test: whitespace between elements should not create separate text nodes
+ // but the space should be preserved as part of one of the adjacent span instances
+ // This ensures the second span gets text-content control in settings panel
+ expect(
+ await html.onPaste?.(`