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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Binary file added public/ads/horizontal-mobile.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/ads/horizontal.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 4 additions & 1 deletion src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ 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';
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('');
Expand Down Expand Up @@ -65,7 +67,8 @@ function App() {
<h1>Python Dict to JSON Converter</h1>
<p className="subheading">Paste your Python dictionary to convert it to valid JSON format instantly</p>
<Toast show={showToast} />

<TshirtAds />

{isMobile && (
<TabSelector activeTab={activeTab} setActiveTab={setActiveTab} />
)}
Expand Down
139 changes: 139 additions & 0 deletions src/components/TshirtAds.js
Original file line number Diff line number Diff line change
@@ -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) => (
<div key={slot.position} className={`tshirt-ad tshirt-ad--${slot.position}`}>
<a
href={FOURTHWALL_URL}
target="_blank"
rel="noopener sponsored"
onClick={() => fireEvent('tshirt_ad_click', slot.position)}
aria-label={TSHIRT_ALT}
>
<img
className="tshirt-ad__image"
src={`/ads/${slot.filename}`}
alt={TSHIRT_ALT}
width={slot.width}
height={slot.height}
/>
</a>
<button
type="button"
className="tshirt-ad__dismiss"
onClick={() => handleDismiss(slot.position)}
aria-label="Dismiss ad"
>
×
</button>
<span className="tshirt-ad__label">Ad</span>
</div>
))}
</>,
document.body
);
}

export default TshirtAds;
91 changes: 91 additions & 0 deletions src/styles/ads.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
11 changes: 11 additions & 0 deletions src/styles/layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
Loading