Spin The Wheel

A custom HTML template for Spin the wheel in-app messages. Copy the code, edit the code, and publish.

Spin the Wheel turns an offer into a moment of anticipation. The user taps once, the wheel spins, and a reward is revealed with a code they can copy straight to their clipboard. It is best suited for promotional campaigns such as first-purchase incentives, cart recovery, loyalty rewards, and seasonal sales where a chance-based reward creates excitement and encourages participation.

Configure Spin the Wheel using Custom HTML Code

📘

This Is a Code Template, Not a Product Feature

  • Spin the Wheel is not a widget you enable in the Netcore dashboard, and it will not appear in any menu. It is a block of HTML that you copy from this page and paste into the Code of HTML Editor
  • Netcore does not host, version, or update this template on your behalf.

You are copying code out of this page and pasting it into a html code editor for campaign. Follow these steps in order.

  1. Create an in-app template. See Custom HTML for the full template creation flow.

  2. Copy the entire code block from the Sample HTML Code section on this page, from the first line to the last. Partial copies will not work.

    Creating a new template using Custom HTML Editor

  3. Paste it into the Code Editor and select Validate.

  4. Edit only the CONFIG block at the top of the code. This is where your rewards, colors, copy, and deeplink go. See Editable and Non Editable Section in Custom HTML Code before you change anything.

  5. Replace the placeholder deeplink. The template ships with netcore://your-deeplink-here, which does nothing. Set claimButton.url to a real deeplink registered in your app.

  6. Preview the message in the right pane, then schedule and publish.

📘

Set Up the Underlying Offer Separately

This template displays a reward code and copies it to the user's clipboard. It does not create coupons, apply discounts, or deduct inventory. Create the actual offer in your commerce platform or in Coupon

Sample HTML Code

<!DOCTYPE html>
<html lang="en">

<head>
  <!-- ============================================================= -->
  <!-- CUSTOMIZE ZONE - Configurable Settings                        -->
  <!-- Everything inside CONFIG is safe to edit. This is the ONLY    -->
  <!-- part a marketer/non-developer needs to touch.                 -->
  <!-- ============================================================= -->
  <script>
    const CONFIG = {
      // Show the X close button (true = show, false = hide)
      includeDismissButton: true,



      // Background gradient of the spin card
      background: "linear-gradient(180deg, #FF3672 0%, #8332F9 46.63%, #6708BC 84.62%, #4E15DD 100%)",
     // Webhook tracking (Webhook.site during testing)
      // NOTE: this value looks like a Webhook.site EMAIL address
      // ([email protected]), not an HTTPS endpoint. fetch() can only
      // POST to an http(s) URL, so tracking will silently no-op until
      // this is replaced with your real "https://webhook.site/<token>"
      // URL (or your production analytics endpoint).
      tracking: {
        enabled: true,
        webhookUrl: "https://webhook.site/35bf28a2-5c33-4299-ba7c-cb84d969e87e",
        campaignId: "spin_wheel_apr",
        // User identity. Drop in the Netcore personalization macro that
        //    resolves to your user identifier at send time. Leave "" to
        //    rely on the SDK/parent app to attach identity instead.
        userId: "",
      },


      // Fire confetti when the wheel stops
      enableConfettiOnWin: true,

      // Spin duration in seconds
      spinDurationSeconds: 2,

      // Spin button
      spinButton: {
        enabled: true,
        text: "Spin ",
        background: "#ffffff",
        color: "#1d1d1d",
      },

      // Claim button (shown after the spin)
      claimButton: {
        enabled: true,
        text: "Collect Now",
        background: "#F4357D",
        color: "#ffffff",

        // Netcore deeplink fired on claim. Replace with your app's
        //    deeplink / URL scheme (e.g. "myapp://offers/spin-win").
        url: "netcore://your-deeplink-here",

        // OPTIONAL extra data sent to the app alongside the click.
        //    Set to null if not needed.
        payload: { campaign: "spin_the_wheel" },
      },

      // Main headline (supports HTML like <br>)
      mainTitle: {
        text: "\uD83C\uDF89 Spin the Wheel <br>&amp; Win Big!",
        fontSize: "24px",
        color: "#ffffff",
      },

      // Subtitle / description
      subtitle: {
        text: "Your chance to score discounts, free gifts, and exclusive perks is just a spin away.",
        fontSize: "14px",
        color: "#e8f5ff",
      },

      // Copy-instruction text colors
      copyInstruction: {
        color: "#606060",
        copiedColor: "#F4357D",
      },

      // Center knob of the wheel
      centerCircle: {
        color: "#914DFF",
        borderColor: "#2B2B2B33",
        borderWidth: 2,
        radius: 15,
      },

      // Rewards on the wheel
      //   - title       - headline shown on the reward screen
      //   - text        - label painted inside the wheel sector
      //   - value       - coupon / code copied on click
      //   - subtitle    - small description on the reward screen
      //   - probability - weighting (does NOT need to sum to 1)
      //   - colour      - sector background colour
      rewards: [
        {
          title: "Congrats! You just won a Promo Code!",
          text: "10% OFF",
          value: "DISCOUNT10",
          subtitle: "Use DISCOUNT10 at checkout to get 10% off your order. Valid through April 30.",
          probability: 0.15,
          colour: "#ffffff",
        },
        {
          title: "Congrats! You just won Free Shipping!",
          text: "Free Shipping",
          value: "FREESHIP",
          subtitle: "Use FREESHIP at checkout to enjoy free shipping on your order. Valid through April 30.",
          probability: 0.15,
          colour: "#fc2b46",
        },
        {
          title: "Congrats! You just won Buy 1 Get 1 Free!",
          text: "Buy 1 Get 1 Free",
          value: "B1G1",
          subtitle: "Use B1G1 at checkout and get another item free. Valid through April 30.",
          probability: 0.15,
          colour: "#ffffff",
        },
        {
          title: "Congrats! You just won Early Access!",
          text: "Early Access",
          value: "EARLY",
          subtitle: "Enjoy early access to new arrivals.",
          probability: 0.15,
          colour: "#E3B316",
        },
        {
          title: "Congrats! You just won a $5 Credit!",
          text: "$5 Credit",
          value: "CREDIT5",
          subtitle: "Congratulations! $5 store credit will be applied to your account.",
          probability: 0.15,
          colour: "#ffffff",
        },
        {
          title: "Congrats! You just won a Mystery Gift!",
          text: "Mystery Gift",
          value: "MYSTERY",
          subtitle: "A surprise gift will be sent with your next purchase!",
          probability: 0.15,
          colour: "#fc2b46",
        },
        {
          title: "Congrats! You just won an Exclusive Code!",
          text: "Exclusive Code",
          value: "EXCLUSIVE",
          subtitle: "Use EXCLUSIVE at checkout for a special offer.",
          probability: 0.05,
          colour: "#ffffff",
        },
        {
          title: "Congrats! You just won the Jackpot!",
          text: "Jackpot $100",
          value: "JACKPOT",
          subtitle: "You just won $100. We'll email you the details!",
          probability: 0.05,
          colour: "#fc2b46",
        },
      ],
    };
  </script>
  <!-- ============================================================= -->
  <!-- END CUSTOMIZE ZONE                                              -->
  <!-- ============================================================= -->

  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Spin the Wheel - Win Big!</title>

  <!-- ============================================================= -->
  <!-- DO NOT MODIFY - Netcore Smartech In-App SDK wiring              -->
  <!-- Order matters: jQuery -> Smartech App SDK -> init().            -->
  <!-- This is what makes the message trackable & interactive on the -->
  <!-- Netcore platform. Removing/reordering this breaks the message.-->
  <!-- ============================================================= -->
  <script src="https://code.jquery.com/jquery-3.4.1.min.js"
    integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin="anonymous"></script>
  <script src="https://cdnt.netcoresmartech.com/smartech-app-sdk/scripts/prod/smartech-app-sdk.js?v=1.0.1"></script>
  <script>
    SmartechAppSDK.init();
  </script>
  <!-- ============================================================= -->
  <!-- END SDK wiring                                                  -->
  <!-- ============================================================= -->

  <style>
    :root {
      --spin-duration: 1;
      --shimmer-hue-1: 337deg;
      --shimmer-hue-2: 337deg;
    }

    @property --mask {
      syntax: "<angle>";
      inherits: false;
      initial-value: 33deg;
    }

    @keyframes spin {
      0% { --mask: 0deg; }
      100% { --mask: 360deg; }
    }

    html,
    body {
      height: 100%;
      width: 100%;
      margin: 0;
      display: flex;
      align-items: center;
      justify-content: space-evenly;
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
        Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
    }

    .modal {
      display: flex;
      flex-direction: column;
      align-items: center;
      height: 100%;
      max-height: 580px;
      max-width: 320px;
      padding: 0 5%;
      justify-content: space-evenly;
      width: 100%;
      color: #ffffff;
      text-align: center;
      background: linear-gradient(180deg, #FF4ECD 0%, #8E2DE2 100%);
      border-radius: 36px;
      position: relative;
      box-sizing: border-box;
      overflow: hidden;
    }

    .modal.reward {
      background: #50346A;
      max-height: 455px;
    }

    .reward-title { font-size: 24px; font-weight: 700; }
    .title, .reward-title { margin-top: 30px; }

    /* Close button */
    .close-btn {
      position: absolute;
      top: 18px;
      right: 18px;
      appearance: none;
      border: 0;
      background: none;
      color: #ffffff;
      font-size: 32px;
      line-height: 1;
      cursor: pointer;
      z-index: 20;
    }

    h1 { margin: 0; font-size: 24px; font-weight: 700; }
    p { margin: 0; font-size: 14px; line-height: 1.45; color: #e8f5ff; }

    /* Wheel container */
    .wheel-container {
      position: relative;
      width: clamp(200px, min(260px, 45vh, 80vw), 260px);
      height: clamp(200px, min(260px, 45vh, 80vw), 260px);
      max-width: 260px;
      max-height: 260px;
      aspect-ratio: 1;
      margin: 10px auto 0;
    }

    canvas#wheelCanvas {
      width: 100%;
      height: 100%;
      border-radius: 50%;
      border: 8px solid #1E3DD84D;
      transform: rotate(0deg);
      transition: transform calc(var(--spin-duration) * 1s) cubic-bezier(0.01, 0.05, 0.05, 1);
      box-sizing: border-box;
    }

    /* Pointer arrow */
    .pointer {
      position: absolute;
      top: -14px;
      left: 50%;
      transform: translateX(-100%);
      width: 24px;
      height: 18px;
      z-index: 2;
    }

    .pointer::before {
      content: '';
      position: absolute;
      width: 100%;
      height: 100%;
      background: linear-gradient(0deg, #C68D07 0%, #FFD724 100%);
      border: 2px solid #2B2B2B0D;
      clip-path: polygon(50% 100%, 0% 0%, 100% 0%);
      filter: drop-shadow(0 0 6px rgba(0, 0, 0, 0.2));
    }

    /* Reward display */
    .reward-wrapper {
      display: none;
      width: 100%;
      position: relative;
      border-radius: 18px;
      overflow: visible;
    }

    .reward-wrapper.visible { display: block; }

    .reward-box {
      display: flex;
      flex-direction: column;
      align-items: center;
      gap: 6px;
      padding: 16px 0;
      background: #ffffff;
      color: #1d1d1d;
      border-radius: 18px;
      width: 100%;
      box-sizing: border-box;
      cursor: pointer;
    }

    .reward-main { font-size: 24px; font-weight: 700; }
    .reward-subtext { padding: 0 20px; font-size: 14px; font-weight: 500; }

    .copy-instruction {
      font-family: 'Proxima Nova', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
      font-weight: 500;
      font-size: 14px;
      line-height: 100%;
      text-align: center;
    }

    .copy-btn {
      display: inline;
      appearance: none;
      border: none;
      background: none;
      cursor: pointer;
      font-size: 24px;
      line-height: 1;
      color: #1d1d1d;
      transition: transform 0.1s ease-in-out;
    }

    .copy-btn:active { transform: scale(0.9); }

    /* Spin / Claim button */
    .action-btn {
      width: fit-content;
      padding: 12.5px 40px;
      border: none;
      border-radius: 14px;
      background: #ffffff;
      color: #1d1d1d;
      font-size: 16px;
      font-weight: 700;
      cursor: pointer;
    }

    .action-btn:disabled { opacity: 0.6; cursor: default; }

    /* Pulsating reward ticket */
    @keyframes pulsate {
      0% { transform: scale(1); }
      50% { transform: scale(1.05); }
      100% { transform: scale(1); }
    }

    .pulsate-animation { animation: pulsate 1.5s 3; }

    /* Shimmer effect */
    .shimmer {
      position: absolute;
      inset: -40px;
      border-radius: inherit;
      mix-blend-mode: plus-lighter;
      pointer-events: none;
      mask-image: conic-gradient(from var(--mask, 0deg),
          transparent 0%, transparent 10%, black 36%, black 45%,
          transparent 50%, transparent 60%, black 85%, black 95%, transparent 100%);
      mask-size: cover;
      animation: spin 3s linear infinite both -0.5s;
      z-index: 10;
    }

    .shimmer::before, .shimmer::after {
      content: "";
      border-radius: inherit;
      position: absolute;
      inset: 40px;
      opacity: 1;
    }

    .shimmer::before {
      box-shadow: 0 0 2px 1px hsl(var(--shimmer-hue-1) 90% 58%),
        0 0 9px 6px hsl(var(--shimmer-hue-2) 70% 60%);
      z-index: -1;
    }

    .reward-wrapper.visible .shimmer { opacity: 1; visibility: visible; }
    .reward-wrapper.shimmer-active .shimmer { animation: spin 2s linear infinite; }
  </style>
</head>

<body>
  <!-- ---------------- Spin screen ---------------- -->
  <div class="modal" id="spinModal">
    <!-- data-smt-action="SMTInAppClose" lets the Netcore SDK close
             the message AND record the close event automatically. -->
    <button class="close-btn" aria-label="Close" data-smt-action="SMTInAppClose">&times;</button>

    <h1 class="title" id="mainTitle"></h1>
    <p id="subtitle"></p>

    <div class="wheel-container">
      <div class="pointer"></div>
      <canvas id="wheelCanvas" role="button" tabindex="0" aria-label="Spin the Wheel"></canvas>
    </div>

    <button class="action-btn" id="actionButton">Spin Now</button>
  </div>

  <!-- ---------------- Reward screen ---------------- -->
  <div class="modal reward" id="rewardModal" style="display: none;">
    <button class="close-btn" aria-label="Close" data-smt-action="SMTInAppClose">&times;</button>

    <h1 class="reward-title" id="rewardTitle"></h1>
    <div id="rewardSubtext" class="reward-subtext"></div>

    <div class="reward-wrapper" id="rewardWrapper">
      <span class="shimmer"></span>
      <div class="reward-box ticket" id="rewardBox">
        <div class="reward-main">
          <span id="rewardText"></span>
          <button id="copyBtn" class="copy-btn" title="Copy to clipboard">
            <svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
              <path fill-rule="evenodd" clip-rule="evenodd"
                d="M13 13.8444V17.8444C13 18.4444 12.6 18.8444 12 18.8444H3C2.4 18.8444 2 18.4444 2 17.8444V8.84442C2 8.24442 2.4 7.84442 3 7.84442H7V3.84442C7 3.24442 7.4 2.84442 8 2.84442H17C17.6 2.84442 18 3.24442 18 3.84442V12.8444C18 13.4444 17.6 13.8444 17 13.8444H13ZM12 13.8444H8C7.4 13.8444 7 13.4444 7 12.8444V8.84442H3V17.8444H12V13.8444ZM17 3.84442H8V12.8444H17V3.84442Z"
                fill="#4A4C4C" />
            </svg>
          </button>
        </div>
        <div class="copy-instruction">Click here to copy the code!</div>
      </div>
    </div>

    <button class="action-btn" id="claimButton">Collect Now</button>
  </div>

  <script>
    (function () {
      // -------------------- Element refs --------------------
      const spinModal = document.getElementById('spinModal');
      const rewardModal = document.getElementById('rewardModal');
      const mainTitleEl = document.getElementById('mainTitle');
      const subtitleEl = document.getElementById('subtitle');
      const wheelCanvas = document.getElementById('wheelCanvas');
      const wheelContainer = document.querySelector('.wheel-container');
      const ctx = wheelCanvas.getContext('2d');
      const spinButton = document.getElementById('actionButton');
      const claimButton = document.getElementById('claimButton');
      const rewardWrapper = document.getElementById('rewardWrapper');
      const rewardBox = document.getElementById('rewardBox');
      const rewardTextEl = document.getElementById('rewardText');
      const rewardSubtextEl = document.getElementById('rewardSubtext');
      const copyBtn = document.getElementById('copyBtn');
      const closeButtons = document.querySelectorAll('.close-btn');
      const rewardTitleEl = document.getElementById('rewardTitle');
      const copyInstructionEl = document.querySelector('.copy-instruction');
      let isSpinning = false;
      let currentRotation = 0;

      // ==========================================================
      // DO NOT MODIFY - Netcore SDK interaction helpers
      // ==========================================================

      // Fire a tracked In-App CLICK to the app (deeplink + optional data).
      // Uses the SDK when present; falls back to a plain redirect so the
      // template still "does something" in a bare browser preview.
      function netcoreTrackClick (deeplink, payload) {
        try {
          if (window.SmartechAppSDK && typeof SmartechAppSDK.trackInAppClick === 'function') {
            SmartechAppSDK.trackInAppClick(deeplink, payload || null);
            return;
          }
        } catch (e) { /* swallow - fall through to fallback */ }
        if (deeplink) window.location.href = deeplink;
      }
      // ==========================================================
      // END SDK helpers
      // ==========================================================

      // -------------------- Analytics (webhook tracking) --------------------
      // Sends a JSON event to CONFIG.tracking.webhookUrl whenever tracking
      // is enabled. Wrapped defensively: a bad/unreachable endpoint must
      // never break the spin-to-win flow for the user.
      function trackEvent (eventName, data) {
        if (!CONFIG.tracking || !CONFIG.tracking.enabled || !CONFIG.tracking.webhookUrl) return;
        try {
          const body = JSON.stringify(Object.assign({
            event: eventName,
            campaignId: CONFIG.tracking.campaignId || '',
            userId: CONFIG.tracking.userId || '',
            timestamp: new Date().toISOString(),
          }, data || {}));
          fetch(CONFIG.tracking.webhookUrl, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: body,
            mode: 'no-cors',
          }).catch(function () { /* network errors must never break the UI */ });
        } catch (e) { /* swallow - tracking must never throw */ }
      }

      // -------------------- Apply Config --------------------

      // Modal background
      if (CONFIG.background) spinModal.style.background = CONFIG.background;

      // Dismiss button: hide if disabled. When enabled, the SDK handles
      // close+tracking via data-smt-action; we only run cleanup here.
      if (!CONFIG.includeDismissButton && closeButtons.length > 0) {
        closeButtons.forEach(btn => { btn.style.display = 'none'; });
      } else {
        closeButtons.forEach(btn => {
          btn.addEventListener('click', () => {
            trackEvent('modal_dismissed', {});
            cleanup();
          });
        });
      }

      // Spin duration
      const spinDuration = CONFIG.spinDurationSeconds || 1;
      document.documentElement.style.setProperty('--spin-duration', spinDuration);

      // Main Title & Subtitle
      mainTitleEl.innerHTML = CONFIG.mainTitle.text;
      mainTitleEl.style.fontSize = CONFIG.mainTitle.fontSize;
      mainTitleEl.style.color = CONFIG.mainTitle.color;

      subtitleEl.textContent = CONFIG.subtitle.text;
      subtitleEl.style.fontSize = CONFIG.subtitle.fontSize;
      subtitleEl.style.color = CONFIG.subtitle.color;

      // Copy instruction color
      if (copyInstructionEl && CONFIG.copyInstruction.color) {
        copyInstructionEl.style.color = CONFIG.copyInstruction.color;
      }

      // Action button initial (Spin)
      spinButton.textContent = CONFIG.spinButton.text;
      spinButton.style.background = CONFIG.spinButton.background;
      spinButton.style.color = CONFIG.spinButton.color;
      if (!CONFIG.spinButton.enabled) spinButton.style.display = 'none';

      // Rewards
      const rewards = CONFIG.rewards;

      // -------------------- Canvas Setup & Sizing --------------------
      let resizeObserver;
      let resizeTimeout;

      function setupCanvas () {
        const containerRect = wheelContainer.getBoundingClientRect();
        const dpr = window.devicePixelRatio || 1;
        const computedStyle = window.getComputedStyle(wheelCanvas);
        const borderWidth = parseInt(computedStyle.borderWidth) || 0;

        const availableWidth = containerRect.width - (borderWidth * 2);
        const availableHeight = containerRect.height - (borderWidth * 2);
        let size = Math.min(availableWidth, availableHeight);

        if (size <= 0 || !isFinite(size)) {
          const viewportSize = Math.min(window.innerWidth, window.innerHeight);
          const maxSize = Math.min(260, viewportSize * 0.8);
          size = Math.max(200, maxSize - (borderWidth * 2));
        }
        size = Math.max(size, 150);

        wheelCanvas.style.width = size + 'px';
        wheelCanvas.style.height = size + 'px';
        wheelCanvas.width = size * dpr;
        wheelCanvas.height = size * dpr;
        ctx.scale(dpr, dpr);
      }

      function handleResize () {
        if (resizeTimeout) clearTimeout(resizeTimeout);
        resizeTimeout = setTimeout(() => {
          setupCanvas();
          drawWheel();
        }, 16);
      }

      function initializeCanvas () {
        if (window.ResizeObserver) {
          resizeObserver = new ResizeObserver(handleResize);
          resizeObserver.observe(wheelContainer);
        } else {
          window.addEventListener('resize', handleResize);
        }
        setupCanvas();
        drawWheel();
      }

      function cleanup () {
        if (resizeObserver) resizeObserver.disconnect();
        else window.removeEventListener('resize', handleResize);
        if (resizeTimeout) clearTimeout(resizeTimeout);
      }

      if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', initializeCanvas);
      } else {
        setTimeout(initializeCanvas, 0);
      }

      // -------------------- Reward selection --------------------
      function chooseReward () {
        const total = rewards.reduce((acc, r) => acc + r.probability, 0);
        let rand = Math.random() * total;
        for (const reward of rewards) {
          if (rand < reward.probability) return reward;
          rand -= reward.probability;
        }
        return rewards[rewards.length - 1];
      }

      // -------------------- Draw wheel --------------------
      function drawWheel (rotation = 0) {
        const cssWidth = parseInt(wheelCanvas.style.width) || wheelCanvas.width;
        const cssHeight = parseInt(wheelCanvas.style.height) || wheelCanvas.height;
        const centreX = cssWidth / 2;
        const centreY = cssHeight / 2;
        const outerRadius = Math.min(cssWidth, cssHeight) / 2;
        const segmentAngle = (Math.PI * 2) / rewards.length;

        ctx.clearRect(0, 0, cssWidth, cssHeight);
        ctx.save();
        ctx.translate(centreX, centreY);
        ctx.rotate(rotation);

        rewards.forEach((reward, index) => {
          ctx.beginPath();
          ctx.moveTo(0, 0);
          ctx.arc(0, 0, outerRadius, index * segmentAngle, (index + 1) * segmentAngle);
          ctx.closePath();
          ctx.fillStyle = reward.colour;
          ctx.fill();

          ctx.save();
          ctx.fillStyle = reward.colour.toLowerCase() === '#ffffff' ? '#1d1d1d' : '#ffffff';

          const midAngle = index * segmentAngle + segmentAngle / 2;
          ctx.rotate(midAngle);

          // Segments whose mid-angle falls in the left half of the wheel
          // would otherwise render mirrored/upside-down (the local
          // +x axis points away from "screen up" there). Flip those
          // labels 180deg in place so every label reads upright.
          const normalizedAngle = ((midAngle % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
          const isUpsideDown = normalizedAngle > Math.PI / 2 && normalizedAngle < (Math.PI * 3) / 2;
          const labelX = outerRadius - (outerRadius * 0.04);

          if (isUpsideDown) {
            ctx.translate(labelX, 0);
            ctx.rotate(Math.PI);
            ctx.textAlign = 'left';
          } else {
            ctx.textAlign = 'right';
          }

          const fontSize = Math.max(13, cssWidth * 0.05);
          ctx.font = `bold ${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif`;

          const maxWidth = outerRadius * 0.6;
          const words = reward.text.split(' ');
          let line = '';
          let lines = [];
          for (let i = 0; i < words.length; i++) {
            const testLine = line + words[i] + ' ';
            const metrics = ctx.measureText(testLine);
            if (metrics.width > maxWidth && i > 0) {
              lines.push(line);
              line = words[i] + ' ';
            } else {
              line = testLine;
            }
          }
          lines.push(line);

          const lineHeight = fontSize * 1.1;
          const totalHeight = (lines.length - 1) * lineHeight;
          lines.forEach((ln, lineIndex) => {
            const yPos = 4 + lineIndex * lineHeight - totalHeight / 2;
            const x = isUpsideDown ? 0 : labelX;
            const y = isUpsideDown ? -yPos : yPos;
            ctx.fillText(ln, x, y);
          });

          ctx.restore();
        });

        // Centre knob
        const centerRadius = (CONFIG.centerCircle.radius / 260) * outerRadius * 2;
        ctx.beginPath();
        ctx.arc(0, 0, centerRadius, 0, Math.PI * 2);
        ctx.fillStyle = CONFIG.centerCircle.color;
        ctx.fill();

        if (CONFIG.centerCircle.borderWidth > 0) {
          ctx.strokeStyle = CONFIG.centerCircle.borderColor;
          ctx.lineWidth = (CONFIG.centerCircle.borderWidth / 260) * outerRadius * 2;
          ctx.stroke();
        }

        ctx.restore();
      }

      // -------------------- Spin logic --------------------
      function spin () {
        if (isSpinning) return;
        isSpinning = true;
        trackEvent('spin_button_clicked', {});
        rewardWrapper.classList.remove('visible');

        const selectedReward = chooseReward();
        const selectedIndex = rewards.indexOf(selectedReward);
        const anglePerSegmentDeg = 360 / rewards.length;
        const spins = Math.floor(Math.random() * 3) + 4; // 4-6 full spins
        const pointerOffsetDeg = 270; // pointer sits at top pointing down
        const randomSpaceWithinSegment = anglePerSegmentDeg * Math.random();
        const finalDeg = spins * 360 + (360 - (selectedIndex * anglePerSegmentDeg) - randomSpaceWithinSegment + pointerOffsetDeg);

        currentRotation = finalDeg;
        wheelCanvas.style.transform = `rotate(${currentRotation}deg)`;

        const onTransitionEnd = () => {
          wheelCanvas.removeEventListener('transitionend', onTransitionEnd);
          isSpinning = false;
          showReward(selectedReward);
        };
        wheelCanvas.addEventListener('transitionend', onTransitionEnd);
      }

      function showReward (reward) {
        setTimeout(() => {
          if (wheelContainer) wheelContainer.style.display = 'none';

          rewardTitleEl.textContent = reward.title;
          rewardTextEl.textContent = reward.value;
          rewardSubtextEl.textContent = reward.subtitle;
          rewardWrapper.classList.add('visible');
          rewardWrapper.classList.add('shimmer-active');
          rewardBox.classList.add('pulsate-animation');

          trackEvent('reward_shown', {
            reward_value: reward.value,
            reward_text: reward.text,
            reward_title: reward.title,
          });

          const shimmerElement = rewardWrapper.querySelector('.shimmer');
          if (shimmerElement) {
            shimmerElement.style.animation = 'none';
            shimmerElement.offsetHeight; // reflow
            shimmerElement.style.animation = 'spin 2s linear infinite';
          }

          spinModal.style.display = 'none';
          rewardModal.style.display = 'flex';

          // Claim button - fires a tracked Netcore In-App click
          if (CONFIG.claimButton.enabled) {
            claimButton.textContent = CONFIG.claimButton.text;
            claimButton.style.background = CONFIG.claimButton.background;
            claimButton.style.color = CONFIG.claimButton.color;
            claimButton.addEventListener('click', () => {
              trackEvent('claim_button_clicked', {
                reward_value: reward.value,
                reward_text: reward.text,
                reward_title: reward.title,
              });
              cleanup();
              netcoreTrackClick(CONFIG.claimButton.url, CONFIG.claimButton.payload);
            }, { once: true });
          } else {
            claimButton.style.display = 'none';
          }

          if (CONFIG.enableConfettiOnWin) fireConfetti();
        }, 1000);
      }

      // -------------------- Copy to clipboard --------------------
      function copyToClipboard () {
        const text = rewardTextEl.textContent;
        if (!text) return;
        if (navigator.clipboard && navigator.clipboard.writeText) {
          navigator.clipboard.writeText(text).then(showCopySuccess).catch(() => fallbackCopy(text));
        } else {
          fallbackCopy(text);
        }
      }

      function copyRewardText (e) {
        if (e && e.target === copyBtn || (e.target.closest && e.target.closest('#copyBtn'))) return;
        copyToClipboard();
      }

      copyBtn.addEventListener('click', (e) => {
        e.stopPropagation();
        copyToClipboard();
      });
      rewardWrapper.addEventListener('click', copyRewardText);

      function showCopySuccess () {
        copyInstructionEl.textContent = "Code copied!";
        copyInstructionEl.style.color = CONFIG.copyInstruction.copiedColor;
      }

      function fallbackCopy (text) {
        const input = document.createElement('input');
        input.setAttribute('readonly', '');
        input.setAttribute('value', text);
        input.style.position = 'absolute';
        input.style.left = '-9999px';
        document.body.appendChild(input);
        input.select();
        document.execCommand('copy');
        document.body.removeChild(input);
        showCopySuccess();
      }

      // -------------------- Confetti (self-contained, no library) ----
      function fireConfetti () {
        const colors = ["#FFC700", "#FF0000", "#2E3192", "#41F4BC", "#943DFF", "#FF61E6"];
        const particleCount = 120;
        const gravity = 0.3;
        const confettiCanvas = document.createElement('canvas');
        confettiCanvas.style.position = 'fixed';
        confettiCanvas.style.top = '0';
        confettiCanvas.style.left = '0';
        confettiCanvas.style.width = '100%';
        confettiCanvas.style.height = '100%';
        confettiCanvas.style.pointerEvents = 'none';
        confettiCanvas.style.zIndex = '9999';
        document.body.appendChild(confettiCanvas);

        const confCtx = confettiCanvas.getContext('2d');
        confettiCanvas.width = window.innerWidth;
        confettiCanvas.height = window.innerHeight;
        const particles = [];
        const randomRange = (min, max) => Math.random() * (max - min) + min;

        for (let i = 0; i < particleCount; i++) {
          particles.push({
            x: confettiCanvas.width / 2,
            y: confettiCanvas.height * 0.6,
            w: randomRange(6, 12),
            h: randomRange(8, 16),
            angle: randomRange(0, 360),
            tiltAngleSpeed: randomRange(0.05, 0.12),
            color: colors[Math.floor(Math.random() * colors.length)],
            velocityX: randomRange(-9, 9),
            velocityY: randomRange(-16, -8),
            opacity: 1,
            decay: randomRange(0.0075, 0.015),
          });
        }

        const update = () => {
          confCtx.clearRect(0, 0, confettiCanvas.width, confettiCanvas.height);
          particles.forEach((p, idx) => {
            p.x += p.velocityX;
            p.y += p.velocityY;
            p.velocityY += gravity;
            p.opacity -= p.decay;
            p.angle += p.tiltAngleSpeed * 180 / Math.PI;
            if (p.opacity <= 0 || p.y > confettiCanvas.height + 20) particles.splice(idx, 1);
          });
          draw();
          if (particles.length) requestAnimationFrame(update);
          else confettiCanvas.remove();
        };

        const draw = () => {
          particles.forEach((p) => {
            confCtx.save();
            confCtx.globalAlpha = Math.max(p.opacity, 0);
            confCtx.fillStyle = p.color;
            confCtx.translate(p.x, p.y);
            confCtx.rotate(p.angle * Math.PI / 180);
            confCtx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
            confCtx.restore();
          });
        };

        update();
      }

      // -------------------- Wire up spin triggers --------------------
      spinButton.addEventListener('click', spin);
      wheelCanvas.addEventListener('click', spin);
      wheelContainer.addEventListener('click', spin);

      window.addEventListener('beforeunload', cleanup);
    })();
  </script>
</body>

</html>

Editable and Non Editable Section in Custom HTML Code

The template is one HTML file with three parts. Only the first one is yours to change.

PartThink of it asDo you edit it?
The Settings BlockThe control panel: colors, text, prizes, links, tracking.Yes. This is the only part you edit.
The Netcore ConnectionThe plug that connects the game to the Netcore platform.No. Never.
The Game EngineThe machinery that draws the wheel, spins it, and picks a winner.No. Developers only.

If you are editing anything outside the Settings Block to run a campaign, stop. What you need is already inside it.

📁 Structure of the Template
<script>
const CONFIG = { ... }
</script>
✓ Editable section
The Settings Block. Update your rewards, colors, text, deeplinks, and tracking here.
<script src="jquery..."></script>
<script src="smartech-app-sdk..."></script>
✕ Do not edit
The Netcore Connection. Loads the required libraries and Netcore SDK.
<style> ... </style>
✕ Do not edit
Defines the widget layout, animations, and styling.
<body> ... </body>
✕ Do not edit
Contains the widget structure rendered to users.
<script> ...engine... </script>
✕ Do not edit
The Game Engine. Controls the game logic, animations, reward selection, and SDK interactions.

What the Game Engine Does

You never edit this, but it helps to know what it is doing. The engine reads your settings, paints the wheel with your prizes and colors, spins when tapped, lands on the pre-chosen winner, swaps to the reward screen, shows the coupon with a copy button, fires the confetti, and sends tracking events along the way.

It also handles screen sizes automatically, keeps labels readable on every slice, and makes sure a broken tracking URL can never break the game itself. The user always gets their spin.

📘

Important Point to Remember

Remember Changing Values Is Safe, Changing Key Names Is Not!

  • Inside the CONFIG block, edit the values on the right of each colon. Do not rename the settings themselves. Changing spinDurationSeconds: 2 to spinDurationSeconds: 4 is fine. Renaming it to spinDuration: 4 means the template can no longer find it.
  • Keep the punctuation intact too. A missing comma or quotation mark stops the whole message from rendering.
  • The code below the CONFIG block is what makes the wheel render, spin, and report back to Netcore, which is why DO NOT EDIT it.
📘

"But the Deeplink Is in There, Why Can't I Change It?"

You can, just not there. The Netcore Connection block contains the machine that fires clicks. The deeplink address itself lives in the Settings Block, as claimButton.url.

Change the address, never the machine. That is the whole rule.

Components You Must Not Remove

Three parts of the locked zone are worth knowing by name, because removing them is the most common way a campaign breaks. Each one connects the template to the Netcore Smartech SDK.

ComponentWhy it's required
SDK wiring block (<head>)Loads jQuery and the Netcore Smartech App SDK in the required order.
data-smt-action="SMTInAppClose"Allows the SDK to close the message and record the dismissal event.
netcoreTrackClick()Tracks the CTA click and opens the configured deeplink.

If any of these are missing, the message can still render in the preview pane while failing to close, failing to report, or failing to open your deeplink on a real device.

It is identical in every template, for every customer, on purpose. It is the contract with the platform, not campaign content, so there is nothing campaign-specific in it and no reason to touch it.

Use Case Based Answers

You want toDo this
Change prizes, odds, or colorsEdit the rewards list. Nothing else.
Send winners to a different app screenEdit claimButton.url.
Make one prize far more likelyRaise that prize's probability and lower the others toward zero.
Know which user won which couponFill tracking.userId with a personalization macro.
Fix tracking that is not arrivingCheck that tracking.enabled is true and webhookUrl starts with https://.
Change how the wheel spins or drawsDo not. Raise it with the product team so every template gets the fix.
📘

The Winner Is Chosen Before the Wheel Spins

The template picks the winning prize from your probability weightings the moment the user taps. The spinning animation is presentation, not selection. This is why changing a probability value is the only lever you need to make a prize rare or near-certain.

Update Custom HTML Code for Spin the Wheel

You can customize the following five sections to configure the Spin the Wheel widget for your In-app message campaign. All of them live inside the CONFIG block.

Layout: Configure the overall appearance and behavior of the widget.
SettingDescriptionExample
includeDismissButtonShow or hide the close (×) button.true
backgroundBackground color or CSS gradient for the widget."linear-gradient(180deg, #FF3672 0%, #4E15DD 100%)"
enableConfettiOnWinShow confetti after a winning spin.true
spinDurationSecondsDuration of the wheel animation.2
📘

Recommended Spin Duration

Keep the animation between two-three seconds. Longer spins feel like loading screens and increase drop-offs.

Text & Typography: Customize the content shown on both screens.

Spin Screen

SettingDescription
mainTitle.textMain heading. Supports basic HTML like <br>.
mainTitle.fontSizeHeading size.
mainTitle.colorHeading color.
subtitle.textSupporting text below the heading.
subtitle.fontSizeSubtitle size.
subtitle.colorSubtitle color.

Reward Screen

SettingDescription
copyInstruction.colorInstruction text color before copying.
copyInstruction.copiedColorColor shown after the coupon is copied.
Buttons: Customize the primary actions shown on each screen.

Spin Button

SettingDescription
spinButton.enabledShow or hide the Spin button. Users can still tap the wheel directly.
spinButton.textButton label.
spinButton.backgroundBackground color.
spinButton.colorText color.

Claim Button

SettingDescription
claimButton.enabledShow or hide the Claim button.
claimButton.textButton label.
claimButton.backgroundBackground color.
claimButton.colorText color.
claimButton.urlDeep link opened after the reward is claimed.
claimButton.payloadOptional payload passed to your app. Use null if not required.
Wheel Appearance: Customize the center hub of the wheel.
SettingDescription
centerCircle.colorFill color.
centerCircle.borderColorBorder color.
centerCircle.borderWidthBorder thickness (px).
centerCircle.radiusCenter circle size.
Rewards: Each object in the rewards array becomes one segment on the wheel.
FieldDescription
textLabel shown inside the wheel. Keep it short (2–3 words).
titleHeading displayed after the user wins.
valueCoupon or reward code copied to the clipboard.
subtitleSupporting message, such as expiry or terms.
probabilityRelative chance of winning this reward.
colourSegment background color.

Troubleshoot and FAQs

Q. Why does the wheel not appear in my template?

A. The message may be using a Sticky Header or Sticky Footer position. Switch the placement to Interstitial or Cover.

Q. Why does the wheel appear cropped?

A. The selected message position does not provide enough vertical space. Use a taller placement, such as Interstitial or Cover.

Q. Why do the segment labels overlap or look cramped?

A. The segment labels may be too long, or the wheel may have too many segments. Shorten the text values or reduce the number of segments.

Q. Why doesn't anything happen when I click Collect Now?

A. Verify that claimButton.url has been replaced with a valid deeplink and that the deeplink is registered in your app.

Q. Why doesn't the message close when I click the close button?

A. The data-smt-action="SMTInAppClose" attribute may have been removed from the close button. Restore the attribute to enable the SDK to close the message correctly.

Q. Why doesn't the reward code copy on some devices?

A. Some app webviews restrict clipboard access. If copying is unavailable, users can still view and manually enter the reward code.

Q. Why doesn't the template render at all?

A. The SDK block may have been modified or reordered, or the Netcore Smartech SDK may not be integrated into your app correctly. Ensure the SDK block remains unchanged and is loaded in the required order.



Did this page help you?