diff --git a/.gitignore b/.gitignore
index a77d830..673d986 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,4 +39,7 @@ public/*
!public/.gitkeep
!public/privacy.html
!public/favicon.svg
+!public/ads/
!ads.txt
+# Local-only backups of source mockups — don't track in git
+public/ads/_originals/
diff --git a/public/ads/horizontal-mobile.jpg b/public/ads/horizontal-mobile.jpg
new file mode 100644
index 0000000..7283662
Binary files /dev/null and b/public/ads/horizontal-mobile.jpg differ
diff --git a/public/ads/horizontal.jpg b/public/ads/horizontal.jpg
new file mode 100644
index 0000000..f00dd49
Binary files /dev/null and b/public/ads/horizontal.jpg differ
diff --git a/public/ads/unisex-staple-t-shirt-black-front-6a0df0764472a.png b/public/ads/unisex-staple-t-shirt-black-front-6a0df0764472a.png
new file mode 100644
index 0000000..7bda0a0
Binary files /dev/null and b/public/ads/unisex-staple-t-shirt-black-front-6a0df0764472a.png differ
diff --git a/public/ads/unisex-staple-t-shirt-black-left-front-6a0df07642e27.png b/public/ads/unisex-staple-t-shirt-black-left-front-6a0df07642e27.png
new file mode 100644
index 0000000..4596566
Binary files /dev/null and b/public/ads/unisex-staple-t-shirt-black-left-front-6a0df07642e27.png differ
diff --git a/public/ads/unisex-staple-t-shirt-black-right-front-6a0df0763f3d0.png b/public/ads/unisex-staple-t-shirt-black-right-front-6a0df0763f3d0.png
new file mode 100644
index 0000000..fc4fa7d
Binary files /dev/null and b/public/ads/unisex-staple-t-shirt-black-right-front-6a0df0763f3d0.png differ
diff --git a/public/ads/unisex-staple-t-shirt-black-right-front-6a0df07641fa5.png b/public/ads/unisex-staple-t-shirt-black-right-front-6a0df07641fa5.png
new file mode 100644
index 0000000..6b54166
Binary files /dev/null and b/public/ads/unisex-staple-t-shirt-black-right-front-6a0df07641fa5.png differ
diff --git a/src/App.js b/src/App.js
index 33bd2be..2abede7 100644
--- a/src/App.js
+++ b/src/App.js
@@ -3,6 +3,7 @@ import InputSection from './components/InputSection';
import OutputSection from './components/OutputSection';
import TabSelector from './components/TabSelector';
import Toast from './components/Toast';
+import TshirtAds from './components/TshirtAds';
import { convertToJson } from './utils/converter';
import './styles/variables.css';
import './styles/layout.css';
@@ -10,6 +11,7 @@ import './styles/editor.css';
import './styles/toolbar.css';
import './styles/components.css';
import './styles/responsive.css';
+import './styles/ads.css';
function App() {
const [input, setInput] = useState('');
@@ -65,7 +67,8 @@ function App() {
Python Dict to JSON Converter
Paste your Python dictionary to convert it to valid JSON format instantly
-
+
+
{isMobile && (
)}
diff --git a/src/components/TshirtAds.js b/src/components/TshirtAds.js
new file mode 100644
index 0000000..0671233
--- /dev/null
+++ b/src/components/TshirtAds.js
@@ -0,0 +1,139 @@
+import React, { useState, useEffect } from 'react';
+import { createPortal } from 'react-dom';
+
+const FOURTHWALL_URL = 'https://raisin-pains-shop.fourthwall.com/products/raisin-pains-supersoft-sycophancy-tee';
+
+const DISMISS_KEY = 'tshirt-ad-dismissed-at';
+const DISMISS_TTL_MS = 7 * 24 * 60 * 60 * 1000;
+const TSHIRT_ALT = "Programmer's dict-to-JSON t-shirt — Shop on Fourthwall";
+
+// Pools of images per ad size. Add more filenames here as you produce them.
+// On each page load, a random non-overlapping selection is made for each size.
+const IMAGE_POOLS = {
+ '160x600': [
+ 'unisex-staple-t-shirt-black-front-6a0df0764472a.png',
+ 'unisex-staple-t-shirt-black-left-front-6a0df07642e27.png',
+ 'unisex-staple-t-shirt-black-right-front-6a0df0763f3d0.png',
+ 'unisex-staple-t-shirt-black-right-front-6a0df07641fa5.png',
+ ],
+ '728x90': [
+ 'horizontal.jpg',
+ ],
+ '320x50': [
+ 'horizontal-mobile.jpg',
+ ],
+};
+
+const SIZES = {
+ '160x600': { width: 160, height: 600 },
+ '728x90': { width: 728, height: 90 },
+ '320x50': { width: 320, height: 50 },
+};
+
+const SLOTS = [
+ { position: 'left', size: '160x600' },
+ { position: 'right', size: '160x600' },
+ { position: 'bottom', size: '728x90' },
+ { position: 'bottom-mobile', size: '320x50' },
+];
+
+function shuffle(arr) {
+ const a = [...arr];
+ for (let i = a.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [a[i], a[j]] = [a[j], a[i]];
+ }
+ return a;
+}
+
+// For each ad size, shuffle the pool and assign distinct images to each slot.
+// If the pool is smaller than the number of slots for that size, the assignment
+// wraps around (duplicates allowed only as a fallback).
+function assignImages() {
+ const shuffled = {};
+ const cursor = {};
+ for (const size of Object.keys(IMAGE_POOLS)) {
+ shuffled[size] = shuffle(IMAGE_POOLS[size]);
+ cursor[size] = 0;
+ }
+ return SLOTS.map((slot) => {
+ const pool = shuffled[slot.size];
+ const filename = pool[cursor[slot.size] % pool.length];
+ cursor[slot.size] += 1;
+ return { ...slot, ...SIZES[slot.size], filename };
+ });
+}
+
+function fireEvent(name, position) {
+ if (typeof window !== 'undefined' && typeof window.gtag === 'function') {
+ window.gtag('event', name, { ad_position: position });
+ }
+}
+
+function TshirtAds() {
+ // null = checking storage, true = hidden, false = visible
+ const [dismissed, setDismissed] = useState(null);
+ const [assignedSlots] = useState(assignImages);
+
+ useEffect(() => {
+ let hidden = false;
+ try {
+ const raw = window.localStorage.getItem(DISMISS_KEY);
+ const ts = raw ? parseInt(raw, 10) : 0;
+ if (ts && Date.now() - ts < DISMISS_TTL_MS) {
+ hidden = true;
+ }
+ } catch (e) {
+ // localStorage unavailable (private mode, blocked) — fall through and show ads
+ }
+ setDismissed(hidden);
+ }, []);
+
+ const handleDismiss = (position) => {
+ try {
+ window.localStorage.setItem(DISMISS_KEY, String(Date.now()));
+ } catch (e) {
+ // ignore — still dismiss in-session
+ }
+ fireEvent('tshirt_ad_dismissed', position);
+ setDismissed(true);
+ };
+
+ if (dismissed !== false) return null;
+
+ return createPortal(
+ <>
+ {assignedSlots.map((slot) => (
+
+ ))}
+ >,
+ document.body
+ );
+}
+
+export default TshirtAds;
diff --git a/src/styles/ads.css b/src/styles/ads.css
new file mode 100644
index 0000000..30baf7b
--- /dev/null
+++ b/src/styles/ads.css
@@ -0,0 +1,91 @@
+.tshirt-ad {
+ position: fixed;
+ z-index: 9000;
+ background: #fff;
+ border: 1px solid #e2e8f0;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
+ font-family: var(--font-family);
+}
+
+.tshirt-ad--left {
+ top: 50%;
+ left: 16px;
+ transform: translateY(-50%);
+}
+
+.tshirt-ad--right {
+ top: 50%;
+ right: 16px;
+ transform: translateY(-50%);
+}
+
+.tshirt-ad--bottom,
+.tshirt-ad--bottom-mobile {
+ bottom: 16px;
+ left: 50%;
+ transform: translateX(-50%);
+}
+
+.tshirt-ad__image {
+ display: block;
+ object-fit: contain;
+ background: #fff;
+}
+
+.tshirt-ad__dismiss {
+ position: absolute;
+ top: 4px;
+ right: 4px;
+ width: 24px;
+ height: 24px;
+ background: rgba(255, 255, 255, 0.9);
+ border: 1px solid #cbd5e0;
+ border-radius: 50%;
+ cursor: pointer;
+ font-size: 16px;
+ line-height: 1;
+ color: #4a5568;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.tshirt-ad__dismiss:hover {
+ background: #fff;
+ color: #1a202c;
+}
+
+.tshirt-ad__label {
+ position: absolute;
+ bottom: 4px;
+ left: 4px;
+ font-size: 10px;
+ color: #718096;
+ background: rgba(255, 255, 255, 0.85);
+ padding: 1px 4px;
+ border-radius: 2px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+@media (max-width: 1279px) {
+ .tshirt-ad--left,
+ .tshirt-ad--right {
+ display: none;
+ }
+}
+
+/* Show the wide 728x90 banner only on tablet+ viewports that can fit it */
+@media (max-width: 767px) {
+ .tshirt-ad--bottom {
+ display: none;
+ }
+}
+
+/* Show the 320x50 mobile banner only on phone-width viewports */
+@media (min-width: 768px) {
+ .tshirt-ad--bottom-mobile {
+ display: none;
+ }
+}
diff --git a/src/styles/layout.css b/src/styles/layout.css
index 8d7f28b..80308e5 100644
--- a/src/styles/layout.css
+++ b/src/styles/layout.css
@@ -172,3 +172,14 @@ footer li {
overflow-x: auto;
}
}
+
+/* When 160x600 side rails are visible (viewport >= 1280px), reserve 200px on
+ each side so .content-section and footer don't slide under the ads. The
+ ad occupies 0-176px from the viewport edge; 200px gives 24px of breathing
+ room. Capped at 80% so very wide screens still look balanced. */
+@media (min-width: 1280px) {
+ .content-section,
+ footer {
+ max-width: min(80%, calc(100% - 400px));
+ }
+}