Store Locator in Framer einbinden

Für Framer gibt es unsere Code-Komponente zum Kopieren. Sie legen sie einmal im Projekt an, ziehen sie auf die Seite und tragen Ihre Karte ein. Ohne Komponente geht es mit der Embed-Komponente von Framer.

Code-Komponente zum KopierenEine Datei, die Sie in Framer als Code-Komponente einfügen. Die Embed-Komponente funktioniert weiterhin.

Im Framer Marketplace eingereicht, in PrüfungNach der Freigabe installieren Sie die Komponente mit einem Klick. Bis dahin kopieren Sie den Code von dieser Seite.

My Store Locator für Framer

Code-Komponente · Version 1.0.0 · 13 KB

Passt zu: Framer (Code-Komponente in React/TypeScript, eine Datei)

.tsx-Datei herunterladen

Datei MyStoreLocator-1.0.0.tsx · SHA-256 42ad2006e95cda41c6cec0a1956a2c56745219006926dde193ab45abc64094fa

Was Sie bekommen

Eine Standortkarte mit Liste, Suche und Filtern in Ihrem Framer-Layout. Die Standorte pflegen Sie in My Store Locator. Mit der Code-Komponente stellen Sie Karte, Höhe, Sprache und die Zwei-Klick-Lösung rechts im Eigenschaftenbereich ein, ohne HTML zu bearbeiten.

Voraussetzungen

  • Ein Konto bei My Store Locator (kostenlos registrieren)
  • Eine veröffentlichte Karte (Status „Live“)
  • Der Karten-Token oder die Adresse Ihrer Karte aus dem Embed Studio der App
  • Ein Framer-Projekt, das Sie bearbeiten dürfen

Code-Komponente einfügen

  1. Kopieren Sie den Code unten mit Code kopieren (oder laden Sie oben die Datei herunter und öffnen Sie sie in einem Texteditor).
  2. Öffnen Sie in Framer links den Bereich Assets, klicken Sie bei Code auf + und wählen Sie New component. Nennen Sie die Datei MyStoreLocator.
  3. Ersetzen Sie den gesamten Inhalt der neuen Datei durch den kopierten Code und speichern Sie (Strg + S oder Cmd + S).
  4. Ziehen Sie MyStoreLocator aus Assets → Code auf Ihre Seite und stellen Sie die Breite auf Fill.
  5. Tragen Sie rechts im Eigenschaftenbereich bei Map token Ihren Karten-Token ein. Die Adresse Ihrer Karte (https://app.my-store-locator.com/#/m/…) oder der Einbettungscode gehen auch, die Komponente liest den Token daraus.
  6. Veröffentlichen Sie die Seite und prüfen Sie die Karte auf der Live-Seite.
// My Store Locator – Framer code component.
// Paste this file into a Framer code file (Assets → Code → New file) or use the Marketplace listing.
// Renders the official widget (https://app.my-store-locator.com/widget.js) – the map itself stays in My Store Locator.
// Strict consent: with "Click to load" on, the published site shows a local placeholder and requests widget.js only after the click.
import { addPropertyControls, ControlType, RenderTarget } from "framer"
import { useEffect, useRef, useState, type CSSProperties } from "react"

// ---- shared embed helpers (same code as wix/…/msl-embed.ts and webflow/src/msl-embed.ts) ----

const APP = 'https://app.my-store-locator.com';
const WIDGET = `${APP}/widget.js`;
const TOKEN_RE = /^[A-Za-z0-9_-]{6,64}$/;

export function extractToken(input: unknown): string | null {
  const s = String(input ?? '').trim();
  if (!s) return null;
  const m = s.match(/data-token\s*=\s*["']?([A-Za-z0-9_-]{6,64})/) || s.match(/#\/m\/([A-Za-z0-9_-]{6,64})/) || s.match(/[?&]msl_token=([A-Za-z0-9_-]{6,64})/);
  const t = m ? m[1] : s;
  return TOKEN_RE.test(t) ? t : null;
}

interface EmbedOptions {
  token: string;
  height?: string;           // "auto" | "640px" | "80vh" | "100%"
  lang?: string;             // "de" | "en" | "" (map default)
  consent?: boolean;         // strict click-to-load (default true)
  lazy?: boolean;
  layout?: string;           // "split" (with height auto)
  offset?: number;           // sticky header px (with height auto)
}

/** Placeholder texts – identical to widget.js. */
const CONSENT_TEXT = {
  de: ['Diese Karte wird von my-store-locator.com geladen (eigener Kartenserver in Deutschland). Dabei wird Ihre IP-Adresse an diesen Server übertragen.', 'Karte laden'],
  en: ['This map is loaded from my-store-locator.com (own map server in Germany). Your IP address is transferred to this server.', 'Load map'],
};

/** de when data-lang is de, en when en, otherwise the page's <html lang> (de… → German, everything else English) – like widget.js. */
function consentLang(lang?: string, doc: Document = document): 'de' | 'en' {
  if (lang === 'de' || lang === 'en') return lang;
  return /^de/i.test(doc.documentElement.lang || '') ? 'de' : 'en';
}

let seq = 0;

/** The widget container (no data-consent – consent is handled here before widget.js is loaded). */
function createContainer(o: EmbedOptions, idPrefix: string, doc: Document = document): HTMLDivElement {
  const el = doc.createElement('div');
  el.id = `${idPrefix}-${++seq}`;
  el.className = 'msl-map';
  el.setAttribute('data-mystorelocator', '');
  el.setAttribute('data-token', o.token);
  const height = o.height || '640px';
  el.setAttribute('data-height', height);
  if (o.lang === 'de' || o.lang === 'en') el.setAttribute('data-lang', o.lang);
  if (o.lazy !== false) el.setAttribute('data-lazy', 'true');
  if (height === 'auto' && o.layout === 'split') el.setAttribute('data-layout', 'split');
  if (height === 'auto' && o.offset && o.offset > 0) el.setAttribute('data-offset', String(Math.round(o.offset)));
  if (height === '100%') el.style.height = '100%';
  return el;
}

interface MslApi { mount(el: Element): void; mountAll(root?: Element): void }

/** Loads widget.js once and mounts `el` (new widget: mount API + MutationObserver; old widget: one script per container). */
function loadWidget(el: HTMLElement, doc: Document = document): void {
  const win = doc.defaultView as (Window & { MyStoreLocator?: MslApi }) | null;
  if (win && win.MyStoreLocator && typeof win.MyStoreLocator.mount === 'function') {
    win.MyStoreLocator.mount(el);
    return;
  }
  const pending = doc.querySelector('script[data-msl-widget]:not([data-loaded])');
  if (pending) return;   // still loading: its first scan finds `el`
  const s = doc.createElement('script');
  s.src = WIDGET;
  s.async = true;
  s.setAttribute('data-msl-widget', '');
  s.addEventListener('load', () => s.setAttribute('data-loaded', ''));
  doc.body.appendChild(s);
}

/** Renders the map into `host`: placeholder first when consent is on, widget.js only after the click. */
function renderEmbed(host: HTMLElement, o: EmbedOptions, idPrefix: string): void {
  const doc = host.ownerDocument;
  const el = createContainer(o, idPrefix, doc);
  const start = () => {
    host.replaceChildren(el);
    loadWidget(el, doc);
  };
  if (o.consent === false) return start();
  const height = !o.height || o.height === 'auto' ? (o.height === 'auto' ? '760px' : '640px') : o.height;
  const [text, label] = CONSENT_TEXT[consentLang(o.lang, doc)];
  const box = doc.createElement('div');
  box.setAttribute('data-msl-consent-placeholder', '');
  box.setAttribute('style', `height:${height};display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;background:#f6f7f9;border-radius:12px;font:14px system-ui;text-align:center;padding:24px;box-sizing:border-box`);
  const p = doc.createElement('p');
  p.setAttribute('style', 'max-width:420px;margin:0;color:#475467');
  p.textContent = text;
  const b = doc.createElement('button');
  b.type = 'button';
  b.setAttribute('style', 'background:#5b55d9;color:#fff;border:0;border-radius:10px;padding:10px 18px;font-weight:600;cursor:pointer');
  b.textContent = label;
  b.addEventListener('click', start);
  box.append(p, b);
  host.replaceChildren(box);
}

interface Connected { token: string; map: string }

/**
 * Opens the connect flow in a popup (return_mode=postmessage). Resolves with the chosen map when the app posts
 * { type: 'msl:connected', token, map, state } from https://app.my-store-locator.com with our state; null if the popup is closed.
 */
function connectPopup(platform: string, win: Window = window): Promise<Connected | null> {
  const state = Array.from(win.crypto.getRandomValues(new Uint8Array(16)), (x) => x.toString(16).padStart(2, '0')).join('');
  const origin = win.location.origin;
  const url = `${APP}/connect?platform=${encodeURIComponent(platform)}&return_mode=postmessage&origin=${encodeURIComponent(origin)}&state=${state}`;
  const popup = win.open(url, 'msl-connect', 'popup,width=520,height=760');
  return new Promise((resolve) => {
    let timer: ReturnType<typeof setInterval> | undefined;
    let finished = false;
    const done = (v: Connected | null) => {
      if (finished) return;
      finished = true;
      win.removeEventListener('message', onMessage);
      if (timer) clearInterval(timer);
      resolve(v);
    };
    function onMessage(e: MessageEvent) {
      if (e.origin !== APP) return;
      const d = e.data as { type?: string; token?: string; map?: string; state?: string } | null;
      if (!d || d.type !== 'msl:connected' || d.state !== state) return;
      const token = extractToken(d.token);
      if (token) done({ token, map: String(d.map || '') });
    }
    win.addEventListener('message', onMessage);
    if (!popup) return done(null);
    // the app closes the popup right after posting – give the message a moment before treating "closed" as cancelled
    timer = setInterval(() => {
      if (!popup.closed) return;
      if (timer) clearInterval(timer);
      timer = undefined;
      setTimeout(() => done(null), 1500);
    }, 800);
  });
}

// ---- component ----
const UTM = "utm_source=framer&utm_medium=integration&utm_campaign=framer-plugin"

type HeightMode = "fill" | "auto" | "fixed"

interface Props {
    token?: string
    heightMode?: HeightMode
    fixedHeight?: number
    language?: "auto" | "de" | "en"
    consent?: boolean
    lazy?: boolean
    layout?: "standard" | "split"
    offset?: number
    style?: CSSProperties
}

/**
 * @framerSupportedLayoutWidth any-prefer-fixed
 * @framerSupportedLayoutHeight any
 * @framerIntrinsicWidth 900
 * @framerIntrinsicHeight 640
 * @framerDisableUnlink
 */
export default function MyStoreLocator(props: Props) {
    const {
        token: rawToken = "",
        heightMode = "fixed",
        fixedHeight = 640,
        language = "auto",
        consent = true,
        lazy = true,
        layout = "standard",
        offset = 0,
        style,
    } = props
    const host = useRef<HTMLDivElement>(null)
    const token = extractToken(rawToken)
    const onCanvas = RenderTarget.current() === RenderTarget.canvas
    const [connected, setConnected] = useState<Connected | null>(null)

    useEffect(() => {
        const box = host.current
        if (!box || !token) return
        const height = heightMode === "auto" ? "auto" : heightMode === "fill" ? "100%" : `${Math.max(200, fixedHeight || 640)}px`
        renderEmbed(
            box,
            {
                token,
                height,
                lang: language === "auto" ? "" : language,
                consent: consent && !onCanvas, // the canvas shows the map directly
                lazy,
                layout: heightMode === "auto" ? layout : "",
                offset: heightMode === "auto" ? offset : 0,
            },
            "msl-framer"
        )
        return () => box.replaceChildren()
    }, [token, heightMode, fixedHeight, language, consent, lazy, layout, offset, onCanvas])

    const outer: CSSProperties = {
        width: "100%",
        height: heightMode === "fill" ? "100%" : undefined,
        minHeight: heightMode === "fill" ? 200 : undefined,
        ...style,
    }

    if (!token) {
        return (
            <div style={{ ...outer, ...placeholder }}>
                <strong style={{ fontSize: 16, color: "#101B30" }}>My Store Locator</strong>
                {connected ? (
                    <span style={{ maxWidth: 440 }}>
                        Connected to <b>{connected.map || "your map"}</b>. Paste this token into the <b>Map token</b> property:{" "}
                        <code style={{ userSelect: "all", background: "#fff", padding: "2px 6px", borderRadius: 6 }}>{connected.token}</code>
                    </span>
                ) : (
                    <span style={{ maxWidth: 420 }}>
                        {rawToken
                            ? "This does not look like a map token. Paste the token, the embed code or the map link from My Store Locator → Embed."
                            : "Paste your map token (or the embed code / map link) in the properties panel – or get it with Connect (in the preview)."}
                    </span>
                )}
                <button
                    type="button"
                    onClick={() => connectPopup("framer").then((r) => r && setConnected(r))}
                    style={{ background: "#5B2FF7", color: "#fff", border: 0, borderRadius: 10, padding: "8px 16px", fontWeight: 600, cursor: "pointer" }}
                >
                    Connect My Store Locator
                </button>
                <a href={`${APP}/#/register?${UTM}`} target="_blank" rel="noopener" style={{ color: "#5B2FF7", fontWeight: 600 }}>
                    No map yet? Create one for free →
                </a>
            </div>
        )
    }
    return <div ref={host} style={outer} />
}

const placeholder: CSSProperties = {
    boxSizing: "border-box",
    minHeight: 240,
    display: "flex",
    flexDirection: "column",
    alignItems: "center",
    justifyContent: "center",
    gap: 10,
    padding: 24,
    textAlign: "center",
    font: "14px/1.5 system-ui, sans-serif",
    color: "#475467",
    background: "linear-gradient(135deg, rgba(91,47,247,0.08), rgba(247,44,179,0.08))",
    borderRadius: 16,
}

addPropertyControls(MyStoreLocator, {
    token: {
        type: ControlType.String,
        title: "Map token",
        defaultValue: "",
        placeholder: "Token, embed code or map link",
        description: "From My Store Locator → Embed. [Create a free map](https://app.my-store-locator.com/#/register?utm_source=framer)",
    },
    heightMode: {
        type: ControlType.Enum,
        title: "Height",
        defaultValue: "fixed",
        options: ["fixed", "fill", "auto"],
        optionTitles: ["Fixed", "Fill frame", "Auto (grow with list)"],
        displaySegmentedControl: true,
    },
    fixedHeight: {
        type: ControlType.Number,
        title: "Pixels",
        defaultValue: 640,
        min: 200,
        max: 2000,
        step: 10,
        unit: "px",
        hidden: (p: Props) => p.heightMode !== "fixed",
    },
    layout: {
        type: ControlType.Enum,
        title: "Layout",
        defaultValue: "standard",
        options: ["standard", "split"],
        optionTitles: ["Standard", "Split (list + sticky map)"],
        hidden: (p: Props) => p.heightMode !== "auto",
    },
    offset: {
        type: ControlType.Number,
        title: "Header offset",
        defaultValue: 0,
        min: 0,
        max: 300,
        unit: "px",
        description: "Height of a sticky site header.",
        hidden: (p: Props) => p.heightMode !== "auto",
    },
    language: {
        type: ControlType.Enum,
        title: "Language",
        defaultValue: "auto",
        options: ["auto", "de", "en"],
        optionTitles: ["Map default", "Deutsch", "English"],
    },
    consent: {
        type: ControlType.Boolean,
        title: "Click to load",
        defaultValue: true,
        enabledTitle: "On",
        disabledTitle: "Off",
        description: "GDPR: nothing is loaded from My Store Locator before the visitor clicks (recommended in the EU).",
    },
    lazy: { type: ControlType.Boolean, title: "Lazy load", defaultValue: true, enabledTitle: "On", disabledTitle: "Off" },
})

Die Komponente besteht aus dieser einen Datei und braucht nur framer und react, die Framer mitbringt. Sie lädt ausschließlich das Widget von https://app.my-store-locator.com.

Einstellungen im Eigenschaftenbereich

EinstellungWirkung
Map tokenIhre Karte: Token, Adresse der Karte oder Einbettungscode
HeightFixed (feste Höhe in Pixeln), Fill frame (füllt den Rahmen) oder Auto (wächst mit der Liste, Rahmenhöhe auf Fit stellen)
LayoutBei Auto: Standard oder Split (Liste mit mitlaufender Karte)
Header offsetBei Auto: Höhe einer fixierten Kopfzeile in Pixeln
LanguageSprache der Karte: Standard der Karte, Deutsch oder Englisch
Click to loadZwei-Klick-Lösung, voreingestellt On
Lazy loadKarte erst laden, wenn sie in den sichtbaren Bereich kommt

Ohne Token zeigt die Komponente einen Hinweis mit Connect My Store Locator. In der Vorschau öffnet der Knopf die Anmeldung, und Sie können Ihre Karte wählen. Den angezeigten Token kopieren Sie dann in Map token, denn Framer erlaubt Komponenten nicht, ihre Einstellungen selbst zu ändern.

Ohne Code-Komponente: Embed-Komponente

  1. Kopieren Sie im Embed Studio im Reiter Link & QR die Adresse Ihrer Karte (sie beginnt mit https://app.my-store-locator.com/#/m/).
  2. Öffnen Sie in Framer das Menü Insert, suchen Sie nach Embed und ziehen Sie die Komponente auf die Seite.
  3. Wählen Sie rechts im Bereich Embed den Typ URL und fügen Sie die Adresse ein.
  4. Stellen Sie Breite (z. B. Fill) und Höhe (z. B. 640 px) ein.
  5. Veröffentlichen Sie die Seite und prüfen Sie die Karte auf der Live-Seite.

Alternativ wählen Sie den Typ HTML und fügen den iframe-Code aus dem Embed Studio ein:

<iframe src="https://app.my-store-locator.com/#/m/IHR-TOKEN" title="Standortkarte" style="width:100%;height:640px;border:0;max-width:100%" loading="lazy" allow="geolocation"></iframe>

Für Smartphones legen Sie in Framer über die Breakpoints eine eigene Höhe fest, etwa 560 px.

Datenschutz: Zwei-Klick-Lösung

Bei der Code-Komponente ist Click to load voreingestellt. Auf der veröffentlichten Seite sehen Besucher zuerst einen Platzhalter mit Datenschutzhinweis. Erst nach dem Klick auf Karte laden wird etwas von My Store Locator geladen. Im Framer-Editor zeigt die Komponente die Karte direkt, damit Sie das Layout sehen. Mehr unter DSGVO & Sicherheit.

Bei der Embed-Komponente schalten Sie im Embed Studio „Karte erst nach Klick laden (DSGVO)“ ein und kopieren den Code danach neu. Beim Typ URL übernimmt Framer nur die Adresse. Für den Platzhalter mit Datenschutzhinweis nutzen Sie den Typ HTML mit dem Code, der bei eingeschaltetem Einwilligungsmodus entsteht.

Updates

Eine kopierte Code-Komponente aktualisiert sich nicht von selbst. Für eine neue Version ersetzen Sie den Inhalt der Datei MyStoreLocator durch den Code von dieser Seite. Ihre Einstellungen bleiben erhalten. Sobald die Komponente im Framer Marketplace ist, steht das hier.

Wenn die Karte nicht erscheint

  • Hinweis „This does not look like a map token“: Kopieren Sie Token, Adresse oder Einbettungscode noch einmal vollständig aus dem Embed Studio.
  • Leer im Editor (Embed-Komponente): Prüfen Sie die veröffentlichte Seite oder die Vorschau.
  • „Karte nicht verfügbar“: Die Karte ist noch ein Entwurf. Veröffentlichen Sie sie in der App.

Alle Optionen erklärt die Hilfe zum Einbetten.

FAQ

Code-Komponente oder Embed-Komponente - was ist besser?

Die Code-Komponente: Sie stellen Höhe, Sprache und Zwei-Klick-Lösung im Eigenschaftenbereich ein, und mit Auto wächst die Karte mit der Liste. Die Embed-Komponente ist schneller eingerichtet, wenn Sie nur eine Karte mit fester Höhe brauchen.

Ist die Komponente schon im Framer Marketplace?

Sie ist eingereicht und wird gerade geprüft. Bis zur Freigabe kopieren Sie den Code von dieser Seite. Ihre Seiten funktionieren auch danach weiter.

Kann ich die Karte in mehreren Sprachen zeigen?

Ja. Wählen Sie bei der Code-Komponente unter Language Deutsch oder Englisch. Bei der Embed-Komponente setzen Sie auf der englischen Seite eine Adresse mit ?lang=en ein, die Sie im Embed Studio erzeugen.

Brauche ich einen bestimmten Framer-Tarif?

Für Code-Komponenten und die Embed-Komponente haben wir keine Einschränkung gefunden. Prüfen Sie die aktuellen Framer-Tarife, wenn Sie eine eigene Domain nutzen möchten.

Probieren Sie es mit Ihren eigenen Standorten aus.

Konto anlegen, Standorte importieren, Karte einbinden. Der Free-Plan bleibt kostenlos - Pro können Sie 14 Tage testen.

Demo startenSofort ausprobieren, ohne Anmeldung