Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions index.css
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
--color-dark-error: var(--cal-bg-dark-error);

--color-black: var(--cal-black);

/* Framer plugin colors */
--color-bg-tertiary: var(--framer-color-bg-tertiary);
--color-text-secondary: var(--framer-color-text-secondary);
}

:root {
Expand Down
4 changes: 2 additions & 2 deletions src/common/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ export const Layout = ({ description, linkHref, linkText, children }: { descript
<div className="flex justify-center items-center flex-col h-full text-center gap-2 w-full">
<div className="flex justify-center items-center flex-col h-full text-center gap-4">
<p><img src={CalLogo} height={35} width={35} className="rounded-lg" alt="Cal Logo" /></p>
<h1 className="text-[#333]">Connect to Cal.com</h1>
<p className="max-w-[190px] !text-[#888888] text-balance">{description}</p>
<h1>Connect to Cal.com</h1>
<p className="max-w-[190px] !text-[#999] text-balance">{description}</p>
{linkText && linkHref && <a href={linkHref} target="_blank" className="!text-info">{linkText} &rarr;</a> }
</div>
</div>
Expand Down
101 changes: 86 additions & 15 deletions src/context/embed.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useSelection } from '@/hooks/useSelection';
import { validateEmbed } from '@/utils';
import { isCalComComponentInstance, isEmbedNode, validateEmbed } from '@/utils';
import { normalizeColorToHex } from '@/utils/color';
import { BookerLayouts, EmbedTheme, EmbedThemeConfig, EmbedType, PreviewState } from '@/utils/types';
import type { CanvasNode } from 'framer-plugin';
import React, { createContext, useState, useContext, ReactNode, useEffect } from 'react';
export type Embed = {
info: {
Expand Down Expand Up @@ -74,25 +76,94 @@ export const EmbedProvider: React.FC<{ children: ReactNode }> = ({ children }) =
const selection = useSelection();

useEffect(() => {
// Only run logic when selection or embedInstance changes
if (selection.length === 1 && selection[0].__class === "ComponentInstanceNode" && selection[0].componentName === "Embed") {
const embedInstance = selection[0];
if (selection.length !== 1) return;

// Check if the URL is a string before parsing
if (typeof embedInstance.controls?.url === "string") {
try {
const data: Embed = JSON.parse(embedInstance.controls?.url);
const { isValid, errorMessage } = validateEmbed(data);
const node = selection[0] as CanvasNode & { controls?: Record<string, unknown> };

// Check if the URL is a string before parsing
if (isEmbedNode(selection) && typeof node.controls?.url === "string") {
try {
const data: Embed = JSON.parse(node.controls.url);
const { isValid, errorMessage } = validateEmbed(data);

if (isValid) {
setEmbed(data);
} else {
console.error('Invalid embed data:', errorMessage);
}
} catch (err) {
console.error("Error parsing embed instance:", err);
}
return;
}

if (isValid) {
setEmbed(data);
} else {
console.error('Invalid embed data:', errorMessage);
// Cal.com Framer component instance
if (isCalComComponentInstance(selection)) {
const controls = (node.controls ?? {}) as {
eventLink?: string;
theme?: EmbedThemeConfig;
lightTheme?: string;
darkTheme?: string;
layout?: BookerLayouts;
};
const { eventLink, theme, lightTheme, darkTheme, layout } = controls;

let origin = embed.info.origin;
let username = embed.info.username;
let eventTypeSlug = embed.info.eventTypeSlug;

if (typeof eventLink === "string" && eventLink.length > 0) {
try {
const url = new URL(eventLink);
const segments = url.pathname.split("/").filter(Boolean);
if (segments.length >= 2) {
username = segments[segments.length - 2];
eventTypeSlug = segments[segments.length - 1];
}
origin = `${url.protocol}//${url.host}`;
} catch {
const parts = eventLink.split("/").filter(Boolean);
if (parts.length >= 2) {
username = parts[parts.length - 2];
eventTypeSlug = parts[parts.length - 1];
}
} catch (err) {
console.error("Error parsing embed instance:", err);
}
}

setEmbed((prev) => {
const normalizedLight =
normalizeColorToHex(lightTheme ?? prev.settings.colorLight) ??
defaultBrandColor.brandColor;
const normalizedDark =
normalizeColorToHex(darkTheme ?? prev.settings.colorDark) ??
defaultBrandColor.darkBrandColor;

return {
...prev,
info: {
origin,
username,
eventTypeSlug,
},
settings: {
...prev.settings,
theme: (theme ?? prev.settings.theme) as EmbedThemeConfig,
colorLight: normalizedLight,
colorDark: normalizedDark,
layout: (layout ?? prev.settings.layout) as BookerLayouts,
},
previewState: {
...prev.previewState,
theme: (theme ?? prev.previewState.theme) as EmbedTheme,
layout: (layout ?? prev.previewState.layout) as BookerLayouts,
palette: {
...prev.previewState.palette,
brandColor: normalizedLight,
darkBrandColor: normalizedDark,
},
},
};
});
}
}, [selection]);

Expand Down
66 changes: 66 additions & 0 deletions src/utils/color.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
const HEX_REGEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;

const RGB_REGEX = /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/i;

const RGBA_REGEX =
/^rgba\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*([01](?:\.\d+)?|\d?\.\d+)\s*\)$/i;

const clampChannel = (value: number) => {
if (Number.isNaN(value)) return 0;
return Math.min(255, Math.max(0, value));
};

const channelToHex = (value: number) => {
const clamped = clampChannel(value);
const hex = clamped.toString(16).padStart(2, "0");
return hex.toLowerCase();
};

/**
* Normalizes a color string into a 6‑digit hex string.
*
* - Accepts:
* - `#rgb` or `#rrggbb`
* - `rgb(r, g, b)`
* - `rgba(r, g, b, a)` (alpha is discarded)
* - Returns `null` if the format is unsupported.
*/
export const normalizeColorToHex = (input?: string | null): string | null => {
if (typeof input !== "string") return null;

const value = input.trim();

// Already hex
if (HEX_REGEX.test(value)) {
// Expand #rgb to #rrggbb
if (value.length === 4) {
const r = value[1];
const g = value[2];
const b = value[3];
return `#${r}${r}${g}${g}${b}${b}`.toLowerCase();
}
return value.toLowerCase();
}

// rgb(...)
const rgbMatch = value.match(RGB_REGEX);
if (rgbMatch) {
const r = parseInt(rgbMatch[1], 10);
const g = parseInt(rgbMatch[2], 10);
const b = parseInt(rgbMatch[3], 10);

return `#${channelToHex(r)}${channelToHex(g)}${channelToHex(b)}`;
}

// rgba(...) – alpha is ignored
const rgbaMatch = value.match(RGBA_REGEX);
if (rgbaMatch) {
const r = parseInt(rgbaMatch[1], 10);
const g = parseInt(rgbaMatch[2], 10);
const b = parseInt(rgbaMatch[3], 10);

return `#${channelToHex(r)}${channelToHex(g)}${channelToHex(b)}`;
}

return null;
};
3 changes: 2 additions & 1 deletion src/utils/constants.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export const FRAMER_EMBED_URL = "https://framerusercontent.com/modules/o1PI5S8YtkA5bP5g4dFz/s801VqobGI0Gkh3K9b41/Embed.js"
export const FRAMER_COMPONENT_URL = "https://framer.com/m/framer/Cal.js"
export const FRAMER_COMPONENT_MODULE_URL_REGEX = /^https:\/\/framerusercontent\.com\/modules\/3kSszCA7P49KbVg8UUZO\/[a-zA-Z0-9]+\/Calcom\.js$/;
3 changes: 0 additions & 3 deletions src/utils/framer.ts

This file was deleted.

28 changes: 27 additions & 1 deletion src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { isComponentInstanceNode } from "framer-plugin";
import { FRAMER_COMPONENT_URL, FRAMER_COMPONENT_MODULE_URL_REGEX } from "./constants";

export function validateEmbed(data: any): { isValid: boolean; errorMessage?: string } {
if (typeof data !== 'object' || data === null) {
return { isValid: false, errorMessage: 'Data is not an object or is null' };
Expand Down Expand Up @@ -51,8 +54,31 @@ export function validateEmbed(data: any): { isValid: boolean; errorMessage?: str
return { isValid: true };
}

export const isCalComComponentInstance = (node: CanvasNode[]) => {
if (node.length !== 1 || !isComponentInstanceNode(node[0])) {
return false;
}

const insertURL = node[0].insertURL;
if (!insertURL || typeof insertURL !== 'string') {
return false;
}

// Check if insertURL matches FRAMER_COMPONENT_URL
if (insertURL === FRAMER_COMPONENT_URL) {
return true;
}

// Check if insertURL matches the module URL regex pattern: https://framerusercontent.com/modules/3kSszCA7P49KbVg8UUZO/*/Calcom.js
return FRAMER_COMPONENT_MODULE_URL_REGEX.test(insertURL);
}

export const isEmbedNode = (node: CanvasNode[]) => {
return node.length === 1 && node[0].__class === "ComponentInstanceNode" && node[0].componentName === "Embed";
return node.length === 1 && isComponentInstanceNode(node[0]) && node[0].componentName === "Embed";
}

export const isEditableNode = (node: CanvasNode[]) => {
return isEmbedNode(node) || isCalComComponentInstance(node);
}

export const getEmbedCode = ({ embedType, calLink, previewState, namespace, embedOrigin }: { embedType: EmbedType; calLink: string; previewState: PreviewState; namespace: string, embedOrigin: string }) => {
Expand Down
41 changes: 25 additions & 16 deletions src/views/EventInfoPage.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Layout } from "@/common/Layout"
import { useEmbed } from "@/context/embed";
import { useSelection } from "@/hooks/useSelection";
import { getEmbedCode, isEmbedNode } from "@/utils";
import { FRAMER_EMBED_URL } from "@/utils/constants";
import { getEmbedCode, isEmbedNode, isCalComComponentInstance, isEditableNode } from "@/utils";
import { FRAMER_COMPONENT_URL } from "@/utils/constants";
import { EmbedType } from "@/utils/types";
import { framer, useIsAllowedTo } from "framer-plugin";
import { useEffect, useState } from "react";
Expand All @@ -11,7 +11,7 @@ import { useNavigate } from "react-router"
export const EventInfoPageView = () => {
const navigate = useNavigate();
const { embed, setEmbed } = useEmbed();
const isAllowedToAddFrameNode = useIsAllowedTo("addComponentInstance");
const isAllowedToEdit = useIsAllowedTo("addComponentInstance", "Node.setAttributes");
const selection = useSelection();
const [eventTypeLink, setEventTypeLink] = useState("");
const [error, setIsError] = useState<string | null>(null);
Expand Down Expand Up @@ -55,7 +55,16 @@ export const EventInfoPageView = () => {
};

const handleConnectClick = async () => {
if (isEmbedNode(selection)) {
if (!isAllowedToEdit) return;

if (isCalComComponentInstance(selection)) {
const node = selection[0];
await node.setAttributes({
controls: {
eventLink: eventTypeLink,
}
});
} else if (isEmbedNode(selection)) {
const node = selection[0];
const data = { embedType: "inline" as EmbedType, previewState: embed.previewState, calLink: `${embed.info.username}/${embed.info.eventTypeSlug}`, embedOrigin: embed.info.origin, namespace: embed.info.eventTypeSlug };
const code = getEmbedCode(data);
Expand All @@ -66,25 +75,24 @@ export const EventInfoPageView = () => {
url: JSON.stringify(embed),
}
});
navigate("/settings");
} else {
if (!isAllowedToAddFrameNode) return;
const data = { embedType: "inline" as EmbedType, previewState: embed.previewState, calLink: `${embed.info.username}/${embed.info.eventTypeSlug}`, embedOrigin: embed.info.origin, namespace: embed.info.eventTypeSlug }
const code = getEmbedCode(data);
await framer.addComponentInstance({
url: FRAMER_EMBED_URL,
url: FRAMER_COMPONENT_URL,
attributes: {
height: "1040px",
width: "490px",
height: "575px",
width: "1000px",
controls: {
type: "HTML",
HTML: code,
url: JSON.stringify(embed),
eventLink: eventTypeLink,
theme: embed.settings.theme,
lightTheme: embed.settings.colorLight,
darkTheme: embed.settings.colorDark,
layout: embed.settings.layout,
}
}
});
navigate("/settings");
}

navigate("/settings");
};

const handleEventTypeLinkChange = (e: React.ChangeEvent<HTMLInputElement>) => {
Expand All @@ -108,10 +116,11 @@ export const EventInfoPageView = () => {
value={eventTypeLink}
onChange={handleEventTypeLinkChange}
onBlur={handleEventTypeLinkBlur}
autoFocus
/>
{error && <p className="!text-red-500 !text-[10px] text-left mt-1">{error}</p>}
</div>
<button disabled={!isAllowedToAddFrameNode || !eventTypeLink} onClick={() => handleConnectClick()} className="rounded-lg !bg-info !text-white">{isEmbedNode(selection) ? "Update" : "Connect"}</button>
<button disabled={!isAllowedToEdit || !eventTypeLink} onClick={() => handleConnectClick()} className="rounded-lg !bg-info !text-white">{isEditableNode(selection) ? "Update" : "Connect"}</button>
</div>
</Layout>
</div>
Expand Down
4 changes: 2 additions & 2 deletions src/views/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ export const LandingPageView = () => {
linkText="How to create an event type"
>
<div className="flex gap-3 w-full font-800">
<a href="https://app.cal.com/event-types?dialog=new" target="_blank" className="min-h-[110px] cursor-pointer flex flex-col items-center justify-center rounded-lg bg-subtle !text-[12px] !text-[#888888] !p-5 w-full text-sm text-center wrap-break-word leading-none gap-2">
<a href="https://app.cal.com/event-types?dialog=new" target="_blank" className="min-h-[110px] cursor-pointer flex flex-col items-center justify-center rounded-lg bg-bg-tertiary !text-[12px] !text-text-secondary !p-5 w-full text-sm text-center wrap-break-word leading-none gap-2">
<FaExternalLinkSquareAlt size="22" />
<div className="!max-w-[95px] font-semibold"> Create a new event type</div>
</a>
<div onClick={() => navigate("/event-info")} className="cursor-pointer flex flex-col items-center !text-[12px] justify-center rounded-lg !p-5 min-h-24 bg-subtle text-[#888888] w-full text-sm text-center wrap-break-word leading-none gap-2">
<div onClick={() => navigate("/event-info")} className="cursor-pointer flex flex-col items-center !text-[12px] justify-center rounded-lg !p-5 min-h-24 bg-bg-tertiary !text-text-secondary w-full text-sm text-center wrap-break-word leading-none gap-2">
<FaSquarePlus size="22" />
<div className="!max-w-[95px] font-semibold"> Connect an event type</div>
</div>
Expand Down
Loading