> ## Documentation Index
> Fetch the complete documentation index at: https://docs.xanhcard.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Bộ component minh họa thẻ

> Trang thử nghiệm bộ component PhoneMockup, WalletPass và các biến thể xem trước thẻ trong tài liệu XanhCard.

export const LocationPreview = ({glance = {}, pill = "Near you", wallpaper, animate = true, scale = 1, time = "9:41", style}) => {
  const CSS = [".xp-phone{width:260px;height:530px;position:relative;flex-shrink:0;transform-origin:top center}", ".xp-phone-body{background:#0a0a0a;border-radius:34px;position:absolute;inset:0;box-shadow:inset 0 0 0 2px #262626,0 4px 10px rgba(0,0,0,.12),0 24px 48px -12px rgba(0,0,0,.55)}", ".xp-notch{z-index:5;background:#000;border-radius:10px;width:70px;height:19px;position:absolute;top:12px;left:50%;transform:translateX(-50%)}", ".xp-screen{background:#0a0a0e;border-radius:27px;position:absolute;inset:7px;overflow:hidden}", ".xp-wallpaper{object-fit:cover;filter:brightness(.55) saturate(.9);z-index:0;width:100%;height:100%;position:absolute;inset:0;margin-top:unset}", ".xp-statusbar{letter-spacing:.02em;z-index:4;color:#fff;justify-content:space-between;align-items:center;font-size:10px;display:flex;position:absolute;top:13px;left:18px;right:18px}", ".xp-statusbar-time{font-weight:600}", ".xp-statusbar-icons{opacity:.9;align-items:center;gap:5px;display:inline-flex}", ".xp-lockscreen{text-align:center;color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.25);z-index:1;position:absolute;top:60px;left:0;right:0}", ".xp-lockscreen-date{letter-spacing:.01em;font-family:-apple-system,'SF Pro Rounded','SF Pro Display',system-ui,sans-serif;font-size:13px;font-weight:600;line-height:1.1}", ".xp-lockscreen-time{letter-spacing:-.045em;margin-top:-4px;font-family:-apple-system,'SF Pro Rounded','SF Pro Display',system-ui,sans-serif;font-size:82px;font-weight:700;line-height:1}", ".xp-sidebutton{z-index:3;background:#1a1a1a;border-radius:2px 0 0 2px;width:5px;height:64px;position:absolute;top:116px;right:-3px;box-shadow:inset 1px 0 #2a2a2a}", ".xp-pill{-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);color:#fff;white-space:nowrap;background:rgba(255,255,255,.16);border-radius:999px;align-items:center;gap:6px;padding:6px 11px;font-size:10px;font-weight:600;display:inline-flex}", ".xp-glance{-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px);color:#fff;border-radius:16px;padding:12px;box-shadow:0 6px 16px -4px rgba(0,0,0,.4)}", ".xp-glance-head{align-items:center;gap:8px;margin-bottom:8px;display:flex}", ".xp-glance-mark{object-fit:cover;border-radius:4px;width:24px;height:24px}", ".xp-glance-head span{font-size:12px;font-weight:600}", ".xp-glance-label{letter-spacing:.14em;text-transform:uppercase;color:rgba(255,255,255,.65);margin-bottom:2px;font-size:9px;font-weight:500}", ".xp-glance-value{font-size:13px;font-weight:500;line-height:1.3}", ".xp-pill-anim{position:absolute;top:178px;left:0;right:0;text-align:center;z-index:3}", ".xp-glance-anim{position:absolute;bottom:16px;left:11px;right:11px;z-index:3}", ".xp-phone:not(.xp-in):not(.xp-static) .xp-pill-anim,.xp-phone:not(.xp-in):not(.xp-static) .xp-glance-anim{opacity:0}", ".xp-phone.xp-in .xp-pill-anim{animation:xp-pill-rise 6.7s cubic-bezier(.2,.8,.2,1) infinite}", ".xp-phone.xp-in .xp-glance-anim{animation:xp-glance-rise 6.7s cubic-bezier(.2,.8,.2,1) infinite}", ".xp-phone.xp-static .xp-pill-anim,.xp-phone.xp-static .xp-glance-anim{opacity:1;transform:none}", "@keyframes xp-pill-rise{0%,14%{opacity:0;transform:translateY(10px)}26%,86%{opacity:1;transform:translateY(0)}96%,100%{opacity:0;transform:translateY(10px)}}", "@keyframes xp-glance-rise{0%,18%{opacity:0;transform:translateY(20px)}30%,86%{opacity:1;transform:translateY(0)}96%,100%{opacity:0;transform:translateY(20px)}}", "@media (prefers-reduced-motion:reduce){.xp-phone .xp-pill-anim,.xp-phone .xp-glance-anim{animation:none!important}.xp-phone .xp-pill-anim,.xp-phone .xp-glance-anim{opacity:1!important;transform:none!important}}", "@media (max-width:600px){.xp-phone{transform:scale(.68)!important;margin-bottom:-170px}}"].join("");
  const SignalIcon = () => <svg width="12" height="9" viewBox="0 0 12 9" fill="currentColor" aria-hidden="true">
      <rect x="0" y="5" width="2" height="4" rx="0.5" />
      <rect x="3.5" y="3" width="2" height="6" rx="0.5" />
      <rect x="7" y="1" width="2" height="8" rx="0.5" />
      <rect x="10.5" y="0" width="2" height="9" rx="0.5" opacity="0.35" />
    </svg>;
  const WifiIcon = () => <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" aria-hidden="true">
      <path d="M1.5 3.5a8 8 0 0 1 11 0" />
      <path d="M3.8 5.8a4.8 4.8 0 0 1 6.4 0" />
      <circle cx="7" cy="8.2" r="1.1" fill="currentColor" stroke="none" />
    </svg>;
  const BatteryIcon = () => <svg width="20" height="10" viewBox="0 0 22 11" aria-hidden="true">
      <rect x="0.5" y="0.5" width="18" height="10" rx="2.5" fill="none" stroke="currentColor" strokeOpacity="0.4" />
      <rect x="2" y="2" width="12" height="7" rx="1.5" fill="currentColor" />
      <path d="M20.5 3.5v4a2 2 0 0 0 0-4z" fill="currentColor" fillOpacity="0.4" />
    </svg>;
  const ref = useRef(null);
  useEffect(() => {
    const el = ref.current;
    if (!el || !animate) return;
    const reduced = typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (reduced || typeof IntersectionObserver === "undefined") {
      el.classList.add("xp-in");
      return;
    }
    const obs = new IntersectionObserver(entries => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          el.classList.add("xp-in");
          obs.disconnect();
        }
      });
    }, {
      threshold: 0.15
    });
    obs.observe(el);
    return () => obs.disconnect();
  }, [animate]);
  const g = glance || ({});
  const brand = g.brand != null ? g.brand : "XanhCard";
  const logo = g.logo;
  const label = g.label != null ? g.label : "OFFER";
  const value = g.value;
  const color = g.color != null ? g.color : "#1f5e2c";
  const phoneCls = "xp-phone" + (animate ? "" : " xp-static");
  const screenStyle = wallpaper ? {
    background: "#0a0a0e"
  } : {
    background: "linear-gradient(160deg, #16224a 0%, #0a0a0e 60%)"
  };
  return <div style={Object.assign({
    display: "flex",
    justifyContent: "center"
  }, style || ({}))}>
      <style dangerouslySetInnerHTML={{
    __html: CSS
  }} />
      <div ref={ref} className={phoneCls} style={{
    transform: "scale(" + scale + ")"
  }}>
        <div className="xp-phone-body">
          <div className="xp-notch" />
          <div className="xp-screen" style={screenStyle}>
            {wallpaper ? <img className="xp-wallpaper" src={wallpaper} alt="" /> : null}
            <div className="xp-lockscreen">
              <div className="xp-lockscreen-date">Today</div>
              <div className="xp-lockscreen-time">{time}</div>
            </div>
            <div className="xp-pill-anim">
              <div className="xp-pill">
                <svg width="9" height="11" viewBox="0 0 9 11" fill="currentColor" aria-hidden="true">
                  <path d="M4.5 0a4.5 4.5 0 0 0-4.5 4.5C0 7.6 4.5 11 4.5 11s4.5-3.4 4.5-6.5A4.5 4.5 0 0 0 4.5 0Zm0 6.1a1.6 1.6 0 1 1 0-3.2 1.6 1.6 0 0 1 0 3.2Z" />
                </svg>
                <span>{pill}</span>
              </div>
            </div>
            <div className="xp-glance-anim">
              <div className="xp-glance" style={{
    background: color
  }}>
                <div className="xp-glance-head">
                  {logo ? <img className="xp-glance-mark" src={logo} alt="" /> : null}
                  <span>{brand}</span>
                </div>
                <div className="xp-glance-label">{label}</div>
                <div className="xp-glance-value">{value}</div>
              </div>
            </div>
            <div className="xp-statusbar">
              <span className="xp-statusbar-time">{time}</span>
              <span className="xp-statusbar-icons">
                <SignalIcon />
                <WifiIcon />
                <BatteryIcon />
              </span>
            </div>
          </div>
          <div className="xp-sidebutton" />
        </div>
      </div>
    </div>;
};

export const NotificationPreview = ({notification = {}, wallpaper, animate = true, scale = 1, time = "9:41", style}) => {
  const CSS = [".xp-phone{width:260px;height:530px;position:relative;flex-shrink:0;transform-origin:top center}", ".xp-phone-body{background:#0a0a0a;border-radius:34px;position:absolute;inset:0;box-shadow:inset 0 0 0 2px #262626,0 4px 10px rgba(0,0,0,.12),0 24px 48px -12px rgba(0,0,0,.55)}", ".xp-notch{z-index:5;background:#000;border-radius:10px;width:70px;height:19px;position:absolute;top:12px;left:50%;transform:translateX(-50%)}", ".xp-screen{background:#0a0a0e;border-radius:27px;position:absolute;inset:7px;overflow:hidden}", ".xp-wallpaper{object-fit:cover;filter:brightness(.55) saturate(.9);z-index:0;width:100%;height:100%;position:absolute;inset:0,margin-top:unset}", ".xp-statusbar{letter-spacing:.02em;z-index:4;color:#fff;justify-content:space-between;align-items:center;font-size:10px;display:flex;position:absolute;top:13px;left:18px;right:18px}", ".xp-statusbar-time{font-weight:600}", ".xp-statusbar-icons{opacity:.9;align-items:center;gap:5px;display:inline-flex}", ".xp-lockscreen{text-align:center;color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.25);z-index:1;position:absolute;top:60px;left:0;right:0}", ".xp-lockscreen-date{letter-spacing:.01em;font-family:-apple-system,'SF Pro Rounded','SF Pro Display',system-ui,sans-serif;font-size:13px;font-weight:600;line-height:1.1}", ".xp-lockscreen-time{letter-spacing:-.045em;margin-top:-4px;font-family:-apple-system,'SF Pro Rounded','SF Pro Display',system-ui,sans-serif;font-size:82px;font-weight:700;line-height:1}", ".xp-sidebutton{z-index:3;background:#1a1a1a;border-radius:2px 0 0 2px;width:5px;height:64px;position:absolute;top:116px;right:-3px;box-shadow:inset 1px 0 #2a2a2a}", ".xp-notification{-webkit-backdrop-filter:blur(20px);backdrop-filter:blur(20px);background:rgba(255,255,255,.1);border-radius:18px;align-items:center;gap:10px;padding:12px;display:flex;box-shadow:0 1px 3px rgba(0,0,0,.2)}", ".xp-notification-icon{object-fit:cover;border-radius:9px;flex-shrink:0;width:36px;height:36px;box-shadow:0 0 0 .5px rgba(255,255,255,.18)}", ".xp-notification-body{color:#fff;flex:1;min-width:0}", ".xp-notification-meta{color:rgba(255,255,255,.7);justify-content:space-between;align-items:center;margin-bottom:1px;font-size:11px;font-weight:500;display:flex}", ".xp-notification-meta span:first-child{text-transform:uppercase;letter-spacing:.05em;font-size:10px}", ".xp-notification-title{letter-spacing:-.01em;font-size:13px;font-weight:600;line-height:1.2}", ".xp-notification-text{color:rgba(255,255,255,.85);margin-top:2px;font-size:12px;line-height:1.25}", ".xp-notif-anim{position:absolute;top:210px;left:11px;right:11px;z-index:3}", ".xp-phone:not(.xp-in):not(.xp-static) .xp-notif-anim{opacity:0;transform:translateY(-160%)}", ".xp-phone.xp-in .xp-notif-anim{animation:xp-notif-drop 7s cubic-bezier(.2,.8,.2,1) infinite}", ".xp-phone.xp-static .xp-notif-anim{opacity:1;transform:none}", "@keyframes xp-notif-drop{0%,14%{opacity:0;transform:translateY(-160%)}26%,86%{opacity:1;transform:translateY(0)}96%,100%{opacity:0;transform:translateY(-160%)}}", "@media (prefers-reduced-motion:reduce){.xp-phone .xp-notif-anim{animation:none!important}.xp-phone .xp-notif-anim{opacity:1!important;transform:none!important}}", "@media (max-width:600px){.xp-phone{transform:scale(.68)!important;margin-bottom:-170px}}"].join("");
  const SignalIcon = () => <svg width="12" height="9" viewBox="0 0 12 9" fill="currentColor" aria-hidden="true">
      <rect x="0" y="5" width="2" height="4" rx="0.5" />
      <rect x="3.5" y="3" width="2" height="6" rx="0.5" />
      <rect x="7" y="1" width="2" height="8" rx="0.5" />
      <rect x="10.5" y="0" width="2" height="9" rx="0.5" opacity="0.35" />
    </svg>;
  const WifiIcon = () => <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" aria-hidden="true">
      <path d="M1.5 3.5a8 8 0 0 1 11 0" />
      <path d="M3.8 5.8a4.8 4.8 0 0 1 6.4 0" />
      <circle cx="7" cy="8.2" r="1.1" fill="currentColor" stroke="none" />
    </svg>;
  const BatteryIcon = () => <svg width="20" height="10" viewBox="0 0 22 11" aria-hidden="true">
      <rect x="0.5" y="0.5" width="18" height="10" rx="2.5" fill="none" stroke="currentColor" strokeOpacity="0.4" />
      <rect x="2" y="2" width="12" height="7" rx="1.5" fill="currentColor" />
      <path d="M20.5 3.5v4a2 2 0 0 0 0-4z" fill="currentColor" fillOpacity="0.4" />
    </svg>;
  const ref = useRef(null);
  useEffect(() => {
    const el = ref.current;
    if (!el || !animate) return;
    const reduced = typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (reduced || typeof IntersectionObserver === "undefined") {
      el.classList.add("xp-in");
      return;
    }
    const obs = new IntersectionObserver(entries => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          el.classList.add("xp-in");
          obs.disconnect();
        }
      });
    }, {
      threshold: 0.15
    });
    obs.observe(el);
    return () => obs.disconnect();
  }, [animate]);
  const n = notification || ({});
  const app = n.app != null ? n.app : "Wallet";
  const title = n.title != null ? n.title : "XanhCard";
  const text = n.text;
  const icon = n.icon;
  const when = n.time != null ? n.time : "now";
  const phoneCls = "xp-phone" + (animate ? "" : " xp-static");
  const screenStyle = wallpaper ? {
    background: "#0a0a0e"
  } : {
    background: "linear-gradient(160deg, #16224a 0%, #0a0a0e 60%)"
  };
  return <div style={Object.assign({
    display: "flex",
    justifyContent: "center"
  }, style || ({}))}>
      <style dangerouslySetInnerHTML={{
    __html: CSS
  }} />
      <div ref={ref} className={phoneCls} style={{
    transform: "scale(" + scale + ")"
  }}>
        <div className="xp-phone-body">
          <div className="xp-notch" />
          <div className="xp-screen" style={screenStyle}>
            {wallpaper ? <img className="xp-wallpaper" src={wallpaper} alt="" /> : null}
            <div className="xp-lockscreen">
              <div className="xp-lockscreen-date">Today</div>
              <div className="xp-lockscreen-time">{time}</div>
            </div>
            <div className="xp-notif-anim">
              <div className="xp-notification">
                {icon ? <img className="xp-notification-icon" src={icon} alt="" /> : null}
                <div className="xp-notification-body">
                  <div className="xp-notification-meta">
                    <span>{app}</span>
                    <span>{when}</span>
                  </div>
                  <div className="xp-notification-title">{title}</div>
                  {text ? <div className="xp-notification-text">{text}</div> : null}
                </div>
              </div>
            </div>
            <div className="xp-statusbar">
              <span className="xp-statusbar-time">{time}</span>
              <span className="xp-statusbar-icons">
                <SignalIcon />
                <WifiIcon />
                <BatteryIcon />
              </span>
            </div>
          </div>
          <div className="xp-sidebutton" />
        </div>
      </div>
    </div>;
};

export const PassPreview = ({pass = {}, animate = true, scale = 1, time = "9:41", style}) => {
  const CSS = [".xp-phone{width:260px;height:530px;position:relative;flex-shrink:0;transform-origin:top center}", ".xp-phone-body{background:#0a0a0a;border-radius:34px;position:absolute;inset:0;box-shadow:inset 0 0 0 2px #262626,0 4px 10px rgba(0,0,0,.12),0 24px 48px -12px rgba(0,0,0,.55)}", ".xp-notch{z-index:5;background:#000;border-radius:10px;width:70px;height:19px;position:absolute;top:12px;left:50%;transform:translateX(-50%)}", ".xp-screen{background:#0a0a0e;border-radius:27px;position:absolute;inset:7px;overflow:hidden}", ".xp-walletbg{z-index:2;pointer-events:none;background:linear-gradient(#f5f5f7 0%,#e5e5ea 100%);position:absolute;inset:0}", ".xp-statusbar{letter-spacing:.02em;z-index:4;color:#fff;justify-content:space-between;align-items:center;font-size:10px;display:flex;position:absolute;top:13px;left:18px;right:18px}", ".xp-statusbar-time{font-weight:600}", ".xp-statusbar-icons{opacity:.9;align-items:center;gap:5px;display:inline-flex}", ".xp-statusbar-dark{color:#0a0a0a}", ".xp-sidebutton{z-index:3;background:#1a1a1a;border-radius:2px 0 0 2px;width:5px;height:64px;position:absolute;top:116px;right:-3px;box-shadow:inset 1px 0 #2a2a2a}", ".xp-pass{color:#fff;border-radius:14px;overflow:hidden;box-shadow:inset 0 0 0 1px rgba(255,255,255,.1),0 6px 14px -4px rgba(0,0,0,.3)}", ".xp-pass-inner{flex-direction:column;gap:9px;padding:13px;display:flex}", ".xp-pass-header{grid-template-columns:auto 1fr auto;align-items:center;gap:8px;display:grid}", ".xp-pass-logo{object-fit:cover;border-radius:4px;width:26px;height:26px}", ".xp-pass-monogram{letter-spacing:.02em;background:rgba(255,255,255,.15);justify-content:center;align-items:center;font-size:10px;font-weight:700;display:flex}", ".xp-pass-brand{white-space:nowrap;text-overflow:ellipsis;font-size:12px;font-weight:600;overflow:hidden}", ".xp-pass-points{text-align:right}", ".xp-pass-points-label{letter-spacing:.14em;text-transform:uppercase;color:rgba(255,255,255,.65);margin-bottom:2px;font-size:8px;font-weight:500;line-height:1}", ".xp-pass-points-value{font-size:13px;font-weight:600;line-height:1}", ".xp-pass-strip{background-position:50%;background-size:cover;border-radius:6px;height:92px;position:relative;overflow:hidden}", ".xp-pass-strip-overlay{text-shadow:0 1px 3px rgba(0,0,0,.55);background:linear-gradient(rgba(0,0,0,0) 0%,rgba(0,0,0,.15) 45%,rgba(0,0,0,.55) 100%);flex-direction:column;justify-content:flex-end;padding:8px 10px;display:flex;position:absolute;inset:0}", ".xp-pass-strip-label{letter-spacing:.14em;text-transform:uppercase;opacity:.85;margin-bottom:2px;font-size:8px;font-weight:500}", ".xp-pass-strip-value{letter-spacing:-.01em;font-size:15px;font-weight:500;line-height:1.1}", ".xp-pass-tier-label{letter-spacing:.14em;text-transform:uppercase;color:rgba(255,255,255,.55);margin-bottom:2px;font-size:8px;font-weight:500}", ".xp-pass-tier-value{font-size:12px;font-weight:500}", ".xp-pass-qr{background:#fff;border-radius:10px;align-self:center;width:100px;height:100px;margin-top:5px;padding:7px;box-sizing:border-box}", ".xp-pass-qr svg{width:100%;height:100%;display:block}", ".xp-pass-code{background:#fff;border-radius:9px;align-self:center;width:150px;height:44px;margin-top:5px;padding:6px 10px;box-sizing:border-box}", ".xp-pass-code svg{width:100%;height:100%;display:block}", ".xp-pass-anim{position:absolute;top:44px;left:13px;right:13px;z-index:3}", ".xp-phone:not(.xp-in):not(.xp-static) .xp-pass-anim{opacity:0;transform:translateY(-130%)}", ".xp-phone.xp-in .xp-pass-anim{animation:xp-pass-drop 6.4s cubic-bezier(.2,.8,.2,1) infinite}", ".xp-phone.xp-in .xp-sidebutton{animation:xp-btn-press 6.4s ease-out infinite}", ".xp-phone.xp-static .xp-pass-anim{opacity:1;transform:none}", "@keyframes xp-pass-drop{0%,18%{opacity:0;transform:translateY(-130%)}30%,84%{opacity:1;transform:translateY(0)}96%,100%{opacity:0;transform:translateY(-130%)}}", "@keyframes xp-btn-press{0%,8%{transform:translateX(0)}10%,12%{transform:translateX(-2px)}14%,16%{transform:translateX(0)}18%,20%{transform:translateX(-2px)}22%,100%{transform:translateX(0)}}", "@media (prefers-reduced-motion:reduce){.xp-phone .xp-pass-anim,.xp-phone .xp-sidebutton{animation:none!important}.xp-phone .xp-pass-anim{opacity:1!important;transform:none!important}}", "@media (max-width:600px){.xp-phone{transform:scale(.68)!important;margin-bottom:-170px}}"].join("");
  const SignalIcon = () => <svg width="12" height="9" viewBox="0 0 12 9" fill="currentColor" aria-hidden="true">
      <rect x="0" y="5" width="2" height="4" rx="0.5" />
      <rect x="3.5" y="3" width="2" height="6" rx="0.5" />
      <rect x="7" y="1" width="2" height="8" rx="0.5" />
      <rect x="10.5" y="0" width="2" height="9" rx="0.5" opacity="0.35" />
    </svg>;
  const WifiIcon = () => <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" aria-hidden="true">
      <path d="M1.5 3.5a8 8 0 0 1 11 0" />
      <path d="M3.8 5.8a4.8 4.8 0 0 1 6.4 0" />
      <circle cx="7" cy="8.2" r="1.1" fill="currentColor" stroke="none" />
    </svg>;
  const BatteryIcon = () => <svg width="20" height="10" viewBox="0 0 22 11" aria-hidden="true">
      <rect x="0.5" y="0.5" width="18" height="10" rx="2.5" fill="none" stroke="currentColor" strokeOpacity="0.4" />
      <rect x="2" y="2" width="12" height="7" rx="1.5" fill="currentColor" />
      <path d="M20.5 3.5v4a2 2 0 0 0 0-4z" fill="currentColor" fillOpacity="0.4" />
    </svg>;
  const monogram = brand => String(brand || "P").split(/\s+/).map(w => w.charAt(0)).slice(0, 2).join("").toUpperCase();
  const makeQr = value => {
    const N = 21;
    const s = String(value == null ? "" : value);
    let seed = 2166136261 >>> 0;
    for (let i = 0; i < s.length; i++) seed = Math.imul(seed ^ s.charCodeAt(i), 16777619) >>> 0;
    const rand = () => {
      seed = Math.imul(seed ^ seed >>> 15, 2246822507) >>> 0;
      seed = Math.imul(seed ^ seed >>> 13, 3266489909) >>> 0;
      return ((seed ^= seed >>> 16) >>> 0) / 4294967296;
    };
    const zones = [[0, 0], [0, N - 7], [N - 7, 0]];
    const cells = [];
    for (let r = 0; r < N; r++) {
      for (let c = 0; c < N; c++) {
        let on = null;
        for (let z = 0; z < zones.length; z++) {
          const zr = zones[z][0];
          const zc = zones[z][1];
          if (r >= zr && r < zr + 7 && c >= zc && c < zc + 7) {
            const lr = r - zr;
            const lc = c - zc;
            on = lr === 0 || lr === 6 || lc === 0 || lc === 6 || lr >= 2 && lr <= 4 && lc >= 2 && lc <= 4;
            break;
          }
        }
        if (on === null) on = r === 6 || c === 6 ? (r + c) % 2 === 0 : rand() > 0.52;
        if (on) cells.push(<rect key={r + "-" + c} x={c} y={r} width="1" height="1" />);
      }
    }
    return <svg viewBox="0 0 21 21" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
        <rect x="0" y="0" width="21" height="21" fill="#fff" />
        <g fill="#000">{cells}</g>
      </svg>;
  };
  const makeBarcode = value => {
    const s = String(value == null ? "" : value);
    let seed = 7;
    for (let i = 0; i < s.length; i++) seed = seed * 31 + s.charCodeAt(i) >>> 0;
    const rand = () => {
      seed ^= seed << 13;
      seed ^= seed >>> 17;
      seed ^= seed << 5;
      seed >>>= 0;
      return seed / 4294967296;
    };
    const bars = [];
    let x = 0;
    for (let i = 0; i < 26; i++) {
      const w = 1 + Math.floor(rand() * 3);
      if (i % 2 === 0) bars.push(<rect key={i} x={x} y="0" width={w} height="20" />);
      x += w;
    }
    return <svg viewBox={"0 0 " + x + " 20"} preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
        {bars}
      </svg>;
  };
  const ref = useRef(null);
  useEffect(() => {
    const el = ref.current;
    if (!el || !animate) return;
    const reduced = typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (reduced || typeof IntersectionObserver === "undefined") {
      el.classList.add("xp-in");
      return;
    }
    const obs = new IntersectionObserver(entries => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          el.classList.add("xp-in");
          obs.disconnect();
        }
      });
    }, {
      threshold: 0.15
    });
    obs.observe(el);
    return () => obs.disconnect();
  }, [animate]);
  const p = pass || ({});
  const color = p.color != null ? p.color : "#1f5e2c";
  const brand = p.brand != null ? p.brand : "XanhCard";
  const logo = p.logo;
  const pointsLabel = p.pointsLabel != null ? p.pointsLabel : "POINTS";
  const pointsValue = p.pointsValue != null ? p.pointsValue : "1,250";
  const showPoints = p.showPoints !== false;
  const stripImage = p.stripImage;
  const stripLabel = p.stripLabel != null ? p.stripLabel : "CARD";
  const stripValue = p.stripValue != null ? p.stripValue : "Membership";
  const tierLabel = p.tierLabel;
  const tierValue = p.tierValue;
  const code = p.code != null ? p.code : "qr";
  const codeValue = p.codeValue;
  const cardStyle = p.style;
  const qrValue = codeValue || [brand, tierValue, pointsValue].filter(Boolean).join("-");
  const renderPass = () => <div className="xp-pass" style={Object.assign({
    background: color
  }, cardStyle || ({}))}>
      <div className="xp-pass-inner">
        <div className="xp-pass-header">
          {logo ? <img className="xp-pass-logo" src={logo} alt="" /> : <div className="xp-pass-logo xp-pass-monogram">{monogram(brand)}</div>}
          <div className="xp-pass-brand">{brand}</div>
          {showPoints ? <div className="xp-pass-points">
              <div className="xp-pass-points-label">{pointsLabel}</div>
              <div className="xp-pass-points-value">{pointsValue}</div>
            </div> : null}
        </div>
        <div className="xp-pass-strip" style={stripImage ? {
    backgroundImage: "url(" + stripImage + ")"
  } : {
    background: "linear-gradient(120deg, rgba(255,255,255,.28), rgba(255,255,255,.06))"
  }}>
          <div className="xp-pass-strip-overlay">
            <div className="xp-pass-strip-label">{stripLabel}</div>
            <div className="xp-pass-strip-value">{stripValue}</div>
          </div>
        </div>
        {tierLabel ? <div>
            <div className="xp-pass-tier-label">{tierLabel}</div>
            <div className="xp-pass-tier-value">{tierValue}</div>
          </div> : null}
        {code === "qr" ? <div className="xp-pass-qr">{makeQr(qrValue)}</div> : null}
        {code === "barcode" ? <div className="xp-pass-code">{makeBarcode(qrValue)}</div> : null}
      </div>
    </div>;
  const phoneCls = "xp-phone" + (animate ? "" : " xp-static");
  return <div style={Object.assign({
    display: "flex",
    justifyContent: "center"
  }, style || ({}))}>
      <style dangerouslySetInnerHTML={{
    __html: CSS
  }} />
      <div ref={ref} className={phoneCls} style={{
    transform: "scale(" + scale + ")"
  }}>
        <div className="xp-phone-body">
          <div className="xp-notch" />
          <div className="xp-screen">
            <div className="xp-walletbg" />
            <div className="xp-pass-anim">{renderPass()}</div>
            <div className="xp-statusbar xp-statusbar-dark">
              <span className="xp-statusbar-time">{time}</span>
              <span className="xp-statusbar-icons">
                <SignalIcon />
                <WifiIcon />
                <BatteryIcon />
              </span>
            </div>
          </div>
          <div className="xp-sidebutton" />
        </div>
      </div>
    </div>;
};

export const WalletPass = ({color = "#1f5e2c", brand = "XanhCard", logo, pointsLabel = "POINTS", pointsValue = "1,250", showPoints = true, stripImage, stripLabel = "CARD", stripValue = "Membership", tierLabel, tierValue, code = "qr", codeValue, style}) => {
  const CSS = [".xp-pass{color:#fff;border-radius:14px;overflow:hidden;box-shadow:inset 0 0 0 1px rgba(255,255,255,.1),0 6px 14px -4px rgba(0,0,0,.3)}", ".xp-pass-inner{flex-direction:column;gap:9px;padding:13px;display:flex}", ".xp-pass-header{grid-template-columns:auto 1fr auto;align-items:center;gap:8px;display:grid}", ".xp-pass-logo{object-fit:cover;border-radius:4px;width:26px;height:26px}", ".xp-pass-monogram{letter-spacing:.02em;background:rgba(255,255,255,.15);justify-content:center;align-items:center;font-size:10px;font-weight:700;display:flex}", ".xp-pass-brand{white-space:nowrap;text-overflow:ellipsis;font-size:12px;font-weight:600;overflow:hidden}", ".xp-pass-points{text-align:right}", ".xp-pass-points-label{letter-spacing:.14em;text-transform:uppercase;color:rgba(255,255,255,.65);margin-bottom:2px;font-size:8px;font-weight:500;line-height:1}", ".xp-pass-points-value{font-size:13px;font-weight:600;line-height:1}", ".xp-pass-strip{background-position:50%;background-size:cover;border-radius:6px;height:92px;position:relative;overflow:hidden}", ".xp-pass-strip-overlay{text-shadow:0 1px 3px rgba(0,0,0,.55);background:linear-gradient(rgba(0,0,0,0) 0%,rgba(0,0,0,.15) 45%,rgba(0,0,0,.55) 100%);flex-direction:column;justify-content:flex-end;padding:8px 10px;display:flex;position:absolute;inset:0}", ".xp-pass-strip-label{letter-spacing:.14em;text-transform:uppercase;opacity:.85;margin-bottom:2px;font-size:8px;font-weight:500}", ".xp-pass-strip-value{letter-spacing:-.01em;font-size:15px;font-weight:500;line-height:1.1}", ".xp-pass-tier-label{letter-spacing:.14em;text-transform:uppercase;color:rgba(255,255,255,.55);margin-bottom:2px;font-size:8px;font-weight:500}", ".xp-pass-tier-value{font-size:12px;font-weight:500}", ".xp-pass-qr{background:#fff;border-radius:10px;align-self:center;width:100px;height:100px;margin-top:5px;padding:7px;box-sizing:border-box}", ".xp-pass-qr svg{width:100%;height:100%;display:block}", ".xp-pass-code{background:#fff;border-radius:9px;align-self:center;width:150px;height:44px;margin-top:5px;padding:6px 10px;box-sizing:border-box}", ".xp-pass-code svg{width:100%;height:100%;display:block}"].join("");
  const monogram = brand => String(brand || "P").split(/\s+/).map(w => w.charAt(0)).slice(0, 2).join("").toUpperCase();
  const makeQr = value => {
    const N = 21;
    const s = String(value == null ? "" : value);
    let seed = 2166136261 >>> 0;
    for (let i = 0; i < s.length; i++) seed = Math.imul(seed ^ s.charCodeAt(i), 16777619) >>> 0;
    const rand = () => {
      seed = Math.imul(seed ^ seed >>> 15, 2246822507) >>> 0;
      seed = Math.imul(seed ^ seed >>> 13, 3266489909) >>> 0;
      return ((seed ^= seed >>> 16) >>> 0) / 4294967296;
    };
    const zones = [[0, 0], [0, N - 7], [N - 7, 0]];
    const cells = [];
    for (let r = 0; r < N; r++) {
      for (let c = 0; c < N; c++) {
        let on = null;
        for (let z = 0; z < zones.length; z++) {
          const zr = zones[z][0];
          const zc = zones[z][1];
          if (r >= zr && r < zr + 7 && c >= zc && c < zc + 7) {
            const lr = r - zr;
            const lc = c - zc;
            on = lr === 0 || lr === 6 || lc === 0 || lc === 6 || lr >= 2 && lr <= 4 && lc >= 2 && lc <= 4;
            break;
          }
        }
        if (on === null) on = r === 6 || c === 6 ? (r + c) % 2 === 0 : rand() > 0.52;
        if (on) cells.push(<rect key={r + "-" + c} x={c} y={r} width="1" height="1" />);
      }
    }
    return <svg viewBox="0 0 21 21" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
        <rect x="0" y="0" width="21" height="21" fill="#fff" />
        <g fill="#000">{cells}</g>
      </svg>;
  };
  const makeBarcode = value => {
    const s = String(value == null ? "" : value);
    let seed = 7;
    for (let i = 0; i < s.length; i++) seed = seed * 31 + s.charCodeAt(i) >>> 0;
    const rand = () => {
      seed ^= seed << 13;
      seed ^= seed >>> 17;
      seed ^= seed << 5;
      seed >>>= 0;
      return seed / 4294967296;
    };
    const bars = [];
    let x = 0;
    for (let i = 0; i < 26; i++) {
      const w = 1 + Math.floor(rand() * 3);
      if (i % 2 === 0) bars.push(<rect key={i} x={x} y="0" width={w} height="20" />);
      x += w;
    }
    return <svg viewBox={"0 0 " + x + " 20"} preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
        {bars}
      </svg>;
  };
  const value = codeValue || [brand, tierValue, pointsValue].filter(Boolean).join("-");
  return <div>
      <style dangerouslySetInnerHTML={{
    __html: CSS
  }} />
      <div className="xp-pass" style={Object.assign({
    background: color
  }, style || ({}))}>
        <div className="xp-pass-inner">
          <div className="xp-pass-header">
            {logo ? <img className="xp-pass-logo" src={logo} alt="" /> : <div className="xp-pass-logo xp-pass-monogram">{monogram(brand)}</div>}
            <div className="xp-pass-brand">{brand}</div>
            {showPoints ? <div className="xp-pass-points">
                <div className="xp-pass-points-label">{pointsLabel}</div>
                <div className="xp-pass-points-value">{pointsValue}</div>
              </div> : null}
          </div>
          <div className="xp-pass-strip" style={stripImage ? {
    backgroundImage: "url(" + stripImage + ")"
  } : {
    background: "linear-gradient(120deg, rgba(255,255,255,.28), rgba(255,255,255,.06))"
  }}>
            <div className="xp-pass-strip-overlay">
              <div className="xp-pass-strip-label">{stripLabel}</div>
              <div className="xp-pass-strip-value">{stripValue}</div>
            </div>
          </div>
          {tierLabel ? <div>
              <div className="xp-pass-tier-label">{tierLabel}</div>
              <div className="xp-pass-tier-value">{tierValue}</div>
            </div> : null}
          {code === "qr" ? <div className="xp-pass-qr">{makeQr(value)}</div> : null}
          {code === "barcode" ? <div className="xp-pass-code">{makeBarcode(value)}</div> : null}
        </div>
      </div>
    </div>;
};

export const PhoneMockup = ({children, wallpaper, screenBg, time = "9:41", date = "Today", showClock = false, darkStatus = false, hideStatusBar = false, hideNotch = false, hideSideButton = false, walletBg = false, animate = true, scale = 1, style}) => {
  const CSS = [".xp-phone{width:260px;height:530px;position:relative;flex-shrink:0;transform-origin:top center}", ".xp-phone-body{background:#0a0a0a;border-radius:34px;position:absolute;inset:0;box-shadow:inset 0 0 0 2px #262626,0 4px 10px rgba(0,0,0,.12),0 24px 48px -12px rgba(0,0,0,.55)}", ".xp-notch{z-index:5;background:#000;border-radius:10px;width:70px;height:19px;position:absolute;top:12px;left:50%;transform:translateX(-50%)}", ".xp-screen{background:#0a0a0e;border-radius:27px;position:absolute;inset:7px;overflow:hidden}", ".xp-wallpaper{object-fit:cover;filter:brightness(.55) saturate(.9);z-index:0;width:100%;height:100%;position:absolute;inset:0}", ".xp-walletbg{z-index:2;pointer-events:none;background:linear-gradient(#f5f5f7 0%,#e5e5ea 100%);position:absolute;inset:0}", ".xp-statusbar{letter-spacing:.02em;z-index:4;color:#fff;justify-content:space-between;align-items:center;font-size:10px;display:flex;position:absolute;top:13px;left:18px;right:18px}", ".xp-statusbar-time{font-weight:600}", ".xp-statusbar-icons{opacity:.9;align-items:center;gap:5px;display:inline-flex}", ".xp-statusbar-dark{color:#0a0a0a}", ".xp-lockscreen{text-align:center;color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.25);z-index:1;position:absolute;top:60px;left:0;right:0}", ".xp-lockscreen-date{letter-spacing:.01em;font-family:-apple-system,'SF Pro Rounded','SF Pro Display',system-ui,sans-serif;font-size:13px;font-weight:600;line-height:1.1}", ".xp-lockscreen-time{letter-spacing:-.045em;margin-top:-4px;font-family:-apple-system,'SF Pro Rounded','SF Pro Display',system-ui,sans-serif;font-size:82px;font-weight:700;line-height:1}", ".xp-sidebutton{z-index:3;background:#1a1a1a;border-radius:2px 0 0 2px;width:5px;height:64px;position:absolute;top:116px;right:-3px;box-shadow:inset 1px 0 #2a2a2a}", ".xp-pass-anim{position:absolute;top:44px;left:13px;right:13px;z-index:3}", ".xp-notif-anim{position:absolute;top:210px;left:11px;right:11px;z-index:3}", ".xp-pill-anim{position:absolute;top:178px;left:0;right:0;text-align:center;z-index:3}", ".xp-glance-anim{position:absolute;bottom:16px;left:11px;right:11px;z-index:3}", ".xp-phone:not(.xp-in):not(.xp-static) .xp-pass-anim{opacity:0;transform:translateY(-130%)}", ".xp-phone:not(.xp-in):not(.xp-static) .xp-notif-anim{opacity:0;transform:translateY(-160%)}", ".xp-phone:not(.xp-in):not(.xp-static) .xp-pill-anim,.xp-phone:not(.xp-in):not(.xp-static) .xp-glance-anim{opacity:0}", ".xp-phone.xp-in .xp-pass-anim{animation:xp-pass-drop 6.4s cubic-bezier(.2,.8,.2,1) infinite}", ".xp-phone.xp-in .xp-notif-anim{animation:xp-notif-drop 7s cubic-bezier(.2,.8,.2,1) infinite}", ".xp-phone.xp-in .xp-pill-anim{animation:xp-pill-rise 6.7s cubic-bezier(.2,.8,.2,1) infinite}", ".xp-phone.xp-in .xp-glance-anim{animation:xp-glance-rise 6.7s cubic-bezier(.2,.8,.2,1) infinite}", ".xp-phone.xp-in .xp-sidebutton{animation:xp-btn-press 6.4s ease-out infinite}", ".xp-phone.xp-static .xp-pass-anim,.xp-phone.xp-static .xp-notif-anim,.xp-phone.xp-static .xp-pill-anim,.xp-phone.xp-static .xp-glance-anim{opacity:1;transform:none}", "@keyframes xp-pass-drop{0%,18%{opacity:0;transform:translateY(-130%)}30%,84%{opacity:1;transform:translateY(0)}96%,100%{opacity:0;transform:translateY(-130%)}}", "@keyframes xp-notif-drop{0%,14%{opacity:0;transform:translateY(-160%)}26%,86%{opacity:1;transform:translateY(0)}96%,100%{opacity:0;transform:translateY(-160%)}}", "@keyframes xp-pill-rise{0%,14%{opacity:0;transform:translateY(10px)}26%,86%{opacity:1;transform:translateY(0)}96%,100%{opacity:0;transform:translateY(10px)}}", "@keyframes xp-glance-rise{0%,18%{opacity:0;transform:translateY(20px)}30%,86%{opacity:1;transform:translateY(0)}96%,100%{opacity:0;transform:translateY(20px)}}", "@keyframes xp-btn-press{0%,8%{transform:translateX(0)}10%,12%{transform:translateX(-2px)}14%,16%{transform:translateX(0)}18%,20%{transform:translateX(-2px)}22%,100%{transform:translateX(0)}}", "@media (prefers-reduced-motion:reduce){.xp-phone .xp-pass-anim,.xp-phone .xp-notif-anim,.xp-phone .xp-pill-anim,.xp-phone .xp-glance-anim,.xp-phone .xp-sidebutton{animation:none!important}.xp-phone .xp-pass-anim,.xp-phone .xp-notif-anim{opacity:1!important;transform:none!important}.xp-phone .xp-pill-anim,.xp-phone .xp-glance-anim{opacity:1!important;transform:none!important}}", "@media (max-width:600px){.xp-phone{transform:scale(.68)!important;margin-bottom:-170px}}"].join("");
  const SignalIcon = () => <svg width="12" height="9" viewBox="0 0 12 9" fill="currentColor" aria-hidden="true">
      <rect x="0" y="5" width="2" height="4" rx="0.5" />
      <rect x="3.5" y="3" width="2" height="6" rx="0.5" />
      <rect x="7" y="1" width="2" height="8" rx="0.5" />
      <rect x="10.5" y="0" width="2" height="9" rx="0.5" opacity="0.35" />
    </svg>;
  const WifiIcon = () => <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" aria-hidden="true">
      <path d="M1.5 3.5a8 8 0 0 1 11 0" />
      <path d="M3.8 5.8a4.8 4.8 0 0 1 6.4 0" />
      <circle cx="7" cy="8.2" r="1.1" fill="currentColor" stroke="none" />
    </svg>;
  const BatteryIcon = () => <svg width="20" height="10" viewBox="0 0 22 11" aria-hidden="true">
      <rect x="0.5" y="0.5" width="18" height="10" rx="2.5" fill="none" stroke="currentColor" strokeOpacity="0.4" />
      <rect x="2" y="2" width="12" height="7" rx="1.5" fill="currentColor" />
      <path d="M20.5 3.5v4a2 2 0 0 0 0-4z" fill="currentColor" fillOpacity="0.4" />
    </svg>;
  const ref = useRef(null);
  useEffect(() => {
    const el = ref.current;
    if (!el || !animate) return;
    const reduced = typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (reduced || typeof IntersectionObserver === "undefined") {
      el.classList.add("xp-in");
      return;
    }
    const obs = new IntersectionObserver(entries => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          el.classList.add("xp-in");
          obs.disconnect();
        }
      });
    }, {
      threshold: 0.15
    });
    obs.observe(el);
    return () => obs.disconnect();
  }, [animate]);
  const screenStyle = {
    background: screenBg || "#0a0a0e"
  };
  if (!wallpaper && !walletBg && !screenBg) {
    screenStyle.background = "linear-gradient(160deg, #16224a 0%, #0a0a0e 60%)";
  }
  const phoneCls = "xp-phone" + (animate ? "" : " xp-static");
  const statusCls = "xp-statusbar" + (walletBg || darkStatus ? " xp-statusbar-dark" : "");
  return <div style={Object.assign({
    display: "flex",
    justifyContent: "center"
  }, style || ({}))}>
      <style dangerouslySetInnerHTML={{
    __html: CSS
  }} />
      <div ref={ref} className={phoneCls} style={{
    transform: "scale(" + scale + ")"
  }}>
        <div className="xp-phone-body">
          {!hideNotch ? <div className="xp-notch" /> : null}
          <div className="xp-screen" style={screenStyle}>
            {wallpaper ? <img className="xp-wallpaper" src={wallpaper} alt="" /> : null}
            {walletBg ? <div className="xp-walletbg" /> : null}
            {children}
            {showClock ? <div className="xp-lockscreen">
                <div className="xp-lockscreen-date">{date}</div>
                <div className="xp-lockscreen-time">{time}</div>
              </div> : null}
            {!hideStatusBar ? <div className={statusCls}>
                <span className="xp-statusbar-time">{time}</span>
                <span className="xp-statusbar-icons">
                  <SignalIcon />
                  <WifiIcon />
                  <BatteryIcon />
                </span>
              </div> : null}
          </div>
          {!hideSideButton ? <div className="xp-sidebutton" /> : null}
        </div>
      </div>
    </div>;
};

## PassPreview

Thẻ trượt vào màn hình ứng dụng Wallet, kèm hiệu ứng nhấn nút nguồn:

```mdx theme={null}
import { PassPreview } from "/snippets/wallet-preview.jsx"

<PassPreview
  pass={{
    brand: "XanhCard",
    color: "#16A34A",
    logo: "/logo/light.svg",
    pointsLabel: "ĐIỂM",
    pointsValue: "1,250",
    stripLabel: "THẺ",
    stripValue: "Thành viên",
    tierLabel: "HẠNG",
    tierValue: "Hạng Vàng",
    code: "qr",
  }}
/>
```

<PassPreview
  pass={{
brand: "XanhCard",
color: "#16A34A",
pointsLabel: "ĐIỂM",
pointsValue: "1,250",
stripLabel: "THẺ",
stripValue: "Thành viên",
tierLabel: "HẠNG",
tierValue: "Hạng Vàng",
}}
/>

## NotificationPreview

Thông báo đẩy xuất hiện trên màn hình khóa:

```mdx theme={null}
import { NotificationPreview } from "/snippets/wallet-preview.jsx"

<NotificationPreview
  notification={{
    app: "Wallet",
    title: "XanhCard",
    text: "Bạn đã đạt 1.300 điểm. Một ly cà phê miễn phí!",
    time: "bây giờ",
  }}
/>
```

<NotificationPreview
  wallpaper="https://images.unsplash.com/photo-1497435334941-8c899ee9e8e9?w=800&q=80"
  notification={{
app: "Wallet",
title: "XanhCard",
text: "Bạn đã đạt 1.300 điểm. Một ly cà phê miễn phí!",
time: "bây giờ",
}}
/>

## LocationPreview

Gợi ý vị trí và thẻ liên quan trên màn hình khóa:

```mdx theme={null}
import { LocationPreview } from "/snippets/wallet-preview.jsx"

<LocationPreview
  pill="Gần cửa hàng Bảo Sơn"
  glance={{
    brand: "XanhCard",
    color: "#16A34A",
    label: "ƯU ĐÃI",
    value: "Miễn phí refill tại cửa hàng Bảo Sơn",
  }}
/>
```

<LocationPreview
  wallpaper="https://images.unsplash.com/photo-1497435334941-8c899ee9e8e9?w=800&q=80"
  pill="Gần cửa hàng Bảo Sơn"
  glance={{
brand: "XanhCard",
color: "#16A34A",
label: "ƯU ĐÃI",
value: "Miễn phí refill tại cửa hàng Bảo Sơn",
}}
/>

## Wallpaper

Cả bốn component điện thoại đều nhận prop `wallpaper` để hiển thị ảnh nền màn hình khóa (tự làm mờ và tối giản 45%):

```mdx theme={null}
import { PassPreview } from "/snippets/wallet-preview.jsx"

<PassPreview
  wallpaper="/images/wallpaper-demo.svg"
  pass={{ brand: "XanhCard", color: "#16A34A" }}
/>
```

<PassPreview wallpaper="/images/wallpaper-demo.svg" pass={{ brand: "XanhCard", color: "#16A34A", stripValue: "Thành viên", tierLabel: "HẠNG", tierValue: "Hạng Vàng" }} />

Khi có `wallpaper`, thẻ trượt lên trên ảnh nền màn hình khóa với thanh trạng thái màu trắng. Khi không có, `PassPreview` hiển thị nền ứng dụng Wallet sáng với thanh trạng thái màu đen.

## Các component cơ bản

Ngoài ba biến thể trên, file `snippets/wallet-preview.jsx` còn export các component độc lập để tự lắp ghép:

| Component | Mô tả |
| - | - |
| `PhoneMockup` | Khung điện thoại iPhone (notch, thanh trạng thái, nút nguồn). Nội dung màn hình truyền qua `children`. |
| `WalletPass` | Thẻ pass hoàn chỉnh (header, strip, trường dữ liệu, mã QR/barcode). |
| `WalletNotification` | Thẻ thông báo đẩy trên màn hình khóa. |
| `LocationPill` | Pill gợi ý vị trí. |
| `PassGlance` | Thẻ relevance hiển thị ở đáy màn hình khóa. |

### Props chính

**`WalletPass`**: `color`, `brand`, `logo`, `pointsLabel`, `pointsValue`, `showPoints`, `stripImage`, `stripLabel`, `stripValue`, `tierLabel`, `tierValue`, `code` (`"qr"` | `"barcode"` | `null`), `codeValue`, `style`.

**`PhoneMockup`**: `wallpaper`, `screenBg`, `time`, `date`, `showClock`, `darkStatus`, `hideStatusBar`, `hideNotch`, `hideSideButton`, `walletBg`, `animate`, `scale`, `style`, `children`.

**`PassPreview`**: `pass` (object chứa props của `WalletPass`), `wallpaper`, `animate`, `scale`, `time` và các props còn lại của `PhoneMockup`.

**`NotificationPreview`**: `notification` (object chứa props của `WalletNotification`), `wallpaper`, `animate`, `scale`, `time`.

**`LocationPreview`**: `glance` (object chứa props của `PassGlance`), `pill`, `wallpaper`, `animate`, `scale`, `time`.

<Tip>
  Đặt `animate={false}` để tắt vòng lặp hoạt cảnh và hiển thị trạng thái cuối cố định. Hoạt cảnh tự tôn trọng cài đặt `prefers-reduced-motion`.
</Tip>

### Lắp ghép tự do

Bạn có thể đặt bất kỳ nội dung nào vào `PhoneMockup`:

```mdx theme={null}
import { PhoneMockup, WalletPass } from "/snippets/wallet-preview.jsx"

<PhoneMockup walletBg>
  <div className="xp-pass-anim">
    <WalletPass brand="XanhCard" code="barcode" />
  </div>
</PhoneMockup>
```

<PhoneMockup walletBg animate={false}>
  <div className="xp-pass-anim">
    <WalletPass brand="XanhCard" color="#1e3a5f" code="barcode" pointsLabel="GHẾ" pointsValue="B-12" stripLabel="SỰ KIỆN" stripValue="Nhạc hội mùa thu" />
  </div>
</PhoneMockup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.