feat: initial commit met Dockerfile voor Coolify deployment

- React/Express slotmachine marketing app
- Multi-stage Dockerfile (node:22-alpine)
- .dockerignore toegevoegd
- package-lock.json gegenereerd

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Frank Meeuwsen 2026-05-30 10:06:23 +02:00
commit ddfb32444a
17 changed files with 5946 additions and 0 deletions

8
.dockerignore Normal file
View file

@ -0,0 +1,8 @@
node_modules/
dist/
.env
.env.local
.env.*
!.env.example
.DS_Store
*.log

9
.env.example Normal file
View file

@ -0,0 +1,9 @@
# GEMINI_API_KEY: Required for Gemini AI API calls.
# AI Studio automatically injects this at runtime from user secrets.
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"
# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example

17
Dockerfile Normal file
View file

@ -0,0 +1,17 @@
# Stage 1: build
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: runtime
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.cjs"]

20
README.md Normal file
View file

@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://ai.google.dev/static/site-assets/images/share-ais-513315318.png" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/68cdb4a0-8a1b-42dc-9e70-57384f437da0
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

13
index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Google AI Studio App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6
metadata.json Normal file
View file

@ -0,0 +1,6 @@
{
"name": "AI Marketing App Slotmachine",
"description": "Een cartooneske slotmachine die AI app-ideeën genereert voor Nederlandse marketeers, compleet met kant-en-klare, kopieerbare prompts.",
"requestFramePermissions": [],
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
}

4344
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

35
package.json Normal file
View file

@ -0,0 +1,35 @@
{
"name": "react-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "tsx server.ts",
"build": "vite build && esbuild server.ts --bundle --platform=node --format=cjs --packages=external --sourcemap --outfile=dist/server.cjs",
"start": "node dist/server.cjs",
"clean": "rm -rf dist server.js",
"lint": "tsc --noEmit"
},
"dependencies": {
"@google/genai": "^2.4.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"lucide-react": "^0.546.0",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"vite": "^6.2.3",
"express": "^4.21.2",
"dotenv": "^17.2.3",
"motion": "^12.23.24"
},
"devDependencies": {
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.3",
"@types/express": "^4.17.21"
}
}

117
server.ts Normal file
View file

@ -0,0 +1,117 @@
import express from "express";
import path from "path";
import { createServer as createViteServer } from "vite";
import { GoogleGenAI } from "@google/genai";
import dotenv from "dotenv";
dotenv.config();
async function startServer() {
const app = express();
const PORT = 3000;
app.use(express.json());
// Initialize Gemini client safely
let ai: GoogleGenAI | null = null;
const apiKey = process.env.GEMINI_API_KEY;
if (apiKey) {
ai = new GoogleGenAI({
apiKey: apiKey,
httpOptions: {
headers: {
'User-Agent': 'aistudio-build',
}
}
});
}
// API Route to enhance the prompt using Gemini
app.post("/api/enhance-prompt", async (req, res) => {
try {
const { slot1, slot2, slot3, appName } = req.body;
if (!slot1 || !slot2 || !slot3) {
return res.status(400).json({ error: "Onvoldoende gegevens om prompt te genereren." });
}
const cleanAppName = appName || `Marketeer AI Tool - ${slot1}`;
if (!ai) {
// Fallback if no API key is configured
return res.json({
enhancedPrompt: `# SYSTEEMPROMPT: ${cleanAppName}\n\nAls de GEMINI_API_KEY niet is geconfigureerd in AI Studio Secrets, kunt u dit basis-concept gebruiken voor uw marketing AI app:\n\n## Concept Fiche\n- **Doelgroep/Kanaal**: ${slot1}\n- **AI Functie**: ${slot2}\n- **USP / Waarde**: ${slot3}\n\n## Instructie\nOntwerp een overzichtelijke web-app voor Nederlandse marketeers die ${slot1} helpt door middel van ${slot2}, met als direct resultaat dat het ${slot3}!`
});
}
const promptInput = `
Je bent een expert in AI-marketing en Prompt Engineering. Schrijf een extreem krachtige, professionele, en gedetailleerde "Systeem Prompt" (in het Nederlands) die een marketeer direct kan kopiëren en in een AI-model (zoals Claude, ChatGPT, Gemini, of Cursor) kan stoppen om een werkende applicatie te bouwen.
De slotmachine heeft de volgende combinatie gekozen:
1. Doelgroep / Kanaal: ${slot1}
2. Kern AI-technologie of actie: ${slot2}
3. Marketing-waarde of USP: ${slot3}
Voorlopige naam van de app: ${cleanAppName}
De prompt moet een vaste, gestructureerde en hoogwaardige opbouw hebben.
Schrijf de prompt in het Nederlands. Gebruik de jij-vorm.
Geef een instructie die het AI-model dwingt om een complete, werkende full-stack web-app te schrijven.
De structuur MOET zijn:
# SYSTEEMPROMPT: [Voeg hier een creatieve, professionele, bruikbare AI-appnaam toe gerelateerd aan de invoer]
## Rol & Identiteit
Uitleg over welke specifieke marketing-expert of product development rol het AI-model moet aannemen (bijv. "Je bent een topklasse marketingtech-ontwikkelaar met expertise in ${slot1}...").
## Concept & Toegevoegde Waarde
Een toelichting waarom deze app geniaal is voor ${slot1}, hoe ${slot2} technisch optimaal wordt ingezet, en hoe het de unieke waarde "${slot3}" waarmaakt voor de marketeer.
## Cruciale Functionele Specificaties
Geef een overzicht van minimaal 3 specifieke interactieve functies die deze app MOET bevatten (bijv. een invoerformulier waar de gebruiker input geeft, een real-time AI-generatie preview, een handige "Kopieer naar klembord" of "Exporteren als CSV/PDF" knop, of visualisatie-dashboards).
## Technische Architectuur (Gebruiksvriendelijk)
- Frontend: HTML5, Tailwind CSS (voor een prachtig, modern, minimalistisch en responsief ontwerp in lichte en donkere modus), React of vanilla JS.
- Backend: Eenvoudige Node.js of standalone client met een schone mock of proxy voor API-keys zodat de marketeer de code direct kan draaien.
## Stapsgewijs Ontwikkel- en Promptingplan
Instructies aan de AI-assistent over hoe het de app in logische, hapklare stappen moet leveren aan de marketeer (eerst de layout-codering, dan de logica-integratie, dan de deployment-tips op Netlify of Vercel).
## Testscenario's & Marketing Invoer-voorbeelden
Geef ten minste 2 concrete marketing-voorbeelden (gerelateerd aan ${slot1}) die de marketeer direct in de app als test-invoer kan gebruiken om te zien of deze app perfect presteert.
Zorg dat de uiteindelijke output in prachtig opgemaakte Markdown is, met heldere scheidingslijnen (---), vetgedrukte koppen en bulletpoints. Begin direct met de Markdown van de prompt zelf, zonder inleidende of afsluitende zinnen van jou (geen "Natuurlijk! Hier is je prompt:").
`;
const response = await ai.models.generateContent({
model: "gemini-3.5-flash",
contents: promptInput,
});
const enhancedPrompt = response.text || "";
res.json({ enhancedPrompt });
} catch (error: any) {
console.error("Fout bij aanroepen Gemini API: ", error);
res.status(500).json({ error: "Er is een fout opgetreden bij het verrijken van de prompt.", details: error.message });
}
});
// Vite middleware for development
if (process.env.NODE_ENV !== "production") {
const vite = await createViteServer({
server: { middlewareMode: true },
appType: "spa",
});
app.use(vite.middlewares);
} else {
const distPath = path.join(process.cwd(), 'dist');
app.use(express.static(distPath));
app.get('*', (req, res) => {
res.sendFile(path.join(distPath, 'index.html'));
});
}
app.listen(PORT, "0.0.0.0", () => {
console.log(`Server running on http://localhost:${PORT}`);
});
}
startServer();

1009
src/App.tsx Normal file

File diff suppressed because it is too large Load diff

97
src/data.ts Normal file
View file

@ -0,0 +1,97 @@
export interface SlotItem {
id: number;
text: string;
emoji: string;
}
export const slot1Dataset: SlotItem[] = [
{ id: 1, text: "B2B websitebezoekers op LinkedIn", emoji: "💼" },
{ id: 2, text: "Gen-Z op TikTok en Instagram Reels", emoji: "🎬" },
{ id: 3, text: "Slapende e-commerce klanten via e-mail", emoji: "✉️" },
{ id: 4, text: "Lokale consumenten in Google Maps", emoji: "📍" },
{ id: 5, text: "Podcast-luisteraars tijdens de ochtendspits", emoji: "🎧" },
{ id: 6, text: "Bezoekers met een verlaten winkelmandje", emoji: "🛒" },
{ id: 7, text: "Trouwe fans in een loyaliteitsprogramma", emoji: "💎" },
{ id: 8, text: "Nieuwe abonnees op de nieuwsbrief", emoji: "📰" },
{ id: 9, text: "Eventbezoekers via gepersonaliseerde WhatsApps", emoji: "📱" },
{ id: 10, text: "Websitebezoekers met hoge aankoopintentie", emoji: "🔥" }
];
export const slot2Dataset: SlotItem[] = [
{ id: 1, text: "Toonaanpassing & Sentimentanalyse", emoji: "🎭" },
{ id: 2, text: "Geautomatiseerde Video Shorts Generator", emoji: "⭐" },
{ id: 3, text: "Hyper-persoonlijke AI Predictive Copywriting", emoji: "✍️" },
{ id: 4, text: "Interactieve AI product-aanbeveler & quiz", emoji: "🧩" },
{ id: 5, text: "Geautomatiseerde SEO-landingspagina schrijver", emoji: "📝" },
{ id: 6, text: "Slimme LinkedIn carrousel & infographic generator", emoji: "📊" },
{ id: 7, text: "AI-gestuurde feedback & review samenvatter", emoji: "💬" },
{ id: 8, text: "Dynamische prijsstelling & voordeel calculator", emoji: "⚖️" },
{ id: 9, text: "Klantenservice chatbot met humor-instelling", emoji: "🤖" },
{ id: 10, text: "Micro-influencer matchmaker & campagneplanner", emoji: "🤝" }
];
export const slot3Dataset: SlotItem[] = [
{ id: 1, text: "die de conversie direct met 20% verhoogt", emoji: "📈" },
{ id: 2, text: "om gegarandeerd viraal te gaan op socials", emoji: "🚀" },
{ id: 3, text: "die het bouncepercentage drastisch verlaagt", emoji: "🛡️" },
{ id: 4, text: "om koude leads in 3 stappen op te warmen", emoji: "☕" },
{ id: 5, text: "die de wekelijkse schrijftijd met 10 uur verkort", emoji: "⏱️" },
{ id: 6, text: "voor 100% consistente merk-branding", emoji: "🎨" },
{ id: 7, text: "die de advertentiekosten met de helft vermindert", emoji: "💸" },
{ id: 8, text: "om leads binnen 5 minuten op te volgen", emoji: "⚡" },
{ id: 9, text: "voor realtime AI A/B control-testen", emoji: "🧪" },
{ id: 10, text: "om de lokale organische vindbaarheid te skyrocketten", emoji: "🎯" }
];
export const wackySlot1: SlotItem[] = [
{ id: 101, text: "Koffieverslaafde kantoorduiven", emoji: "🐦" },
{ id: 102, text: "De geit van de buurman met LinkedIn Premium", emoji: "🐐" },
{ id: 103, text: "Holografische dinosaurussen in een tikkie-groep", emoji: "🦖" },
{ id: 104, text: "Boze kabouters in de serverruimte", emoji: "🍄" },
{ id: 105, text: "Spitvlammende alpacas op een agile standup", emoji: "🦙" },
{ id: 106, text: "Een pratende kaassoufflé genaamd Evert", emoji: "🧀" },
{ id: 107, text: "De linkerschoen van de Senior Growth Hacker", emoji: "👟" },
{ id: 108, text: "Existentiële stofmijten in de vergaderzaal", emoji: "🌌" }
];
export const wackySlot2: SlotItem[] = [
{ id: 101, text: "Vertaalt Excel-sheets naar Gregoriaans Gezang", emoji: "🎵" },
{ id: 102, text: "Teleporteert koude bitterballen rechtstreeks naar Slack", emoji: "🛸" },
{ id: 103, text: "Laat de muiscursor de Sirtaki dansen", emoji: "💃" },
{ id: 104, text: "Verandert PDF-facturen in Origami-kraanvogels", emoji: "📄" },
{ id: 105, text: "Sproeit digitale confetti bij elke onnodige meeting", emoji: "🎉" },
{ id: 106, text: "Laat de koffiemachine stiekem passief-agressief Frans praten", emoji: "☕" },
{ id: 107, text: "Voorspelt of de printer vandaag zin heeft om te werken", emoji: "🖨️" }
];
export const wackySlot3: SlotItem[] = [
{ id: 101, text: "zodat je baas spontaan begint te jodelen", emoji: "🏔️" },
{ id: 102, text: "om spirituele verlichting van de Belastingdienst te bereiken", emoji: "🧘" },
{ id: 103, text: "die de zwaartekracht op de marketingafdeling met 10% verlaagt", emoji: "🪐" },
{ id: 104, text: "waardoor alle LinkedIn-berichten in blauwe bessen veranderen", emoji: "🫐" },
{ id: 105, text: "wat resulteert in een verplichte polonaise tijdens de wekelijkse demo", emoji: "🥳" },
{ id: 106, text: "waardoor de kantoorkat de nieuwe CMO wordt", emoji: "🐱" }
];
export const wackyLoadingPhrases = [
"Quantum-bitterballen frituren...",
"Existentiële crises verwerken in Excel...",
"Sirtaki-algoritmes synchroniseren...",
"De koffiemachine overtuigen om Nederlands te praten...",
"Polonaise-coördinaten berekenen...",
"Origami-vouwen voor de fiscus...",
"Kabouters omkopen met koffiepads..."
];
export const loadingPhrases = [
"Marketing jargon filteren op synergie...",
"Pixel tracking optimaliseren voor conversie...",
"Influencers scouten in de Randstad...",
"AI-copywriter een sterke bak espresso inschenken...",
"LinkedIn algoritme kalibreren op viraliteit...",
"CTR en bounce rate balanceren in de cloud...",
"Marketeers overtuigen dat koude acquisitie dood is...",
"Gen-Z straattaal vertalen naar enterprise-waarde...",
"AB-testen voorbereiden met onrealistische KPI's...",
"AI-agenten de schuld geven van mislukte marketingcampagnes..."
];

68
src/index.css Normal file
View file

@ -0,0 +1,68 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Space+Grotesk:wght@500;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap');
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-display: "Space Grotesk", sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
}
/* Custom comic/cartoon styling utilities */
.cartoon-border {
border: 4px solid #1A1A1A;
}
.cartoon-shadow {
box-shadow: 8px 8px 0px 0px #1A1A1A;
}
.cartoon-shadow-sm {
box-shadow: 4px 4px 0px 0px #1A1A1A;
}
.cartoon-shadow-lg {
box-shadow: 12px 12px 0px 0px #1A1A1A;
}
.cartoon-shadow-red {
box-shadow: 8px 8px 0px 0px #FF6B6B;
}
.cartoon-shadow-orange {
box-shadow: 8px 8px 0px 0px #1A1A1A;
}
/* Light bulbs blinking animation */
@keyframes blink-odd {
0%, 100% { opacity: 0.3; background-color: #facc15; }
50% { opacity: 1; background-color: #eab308; box-shadow: 0 0 10px #facc15; }
}
@keyframes blink-even {
0%, 100% { opacity: 1; background-color: #ef4444; box-shadow: 0 0 10px #ef4444; }
50% { opacity: 0.3; background-color: #b91c1c; }
}
.bulbs-odd {
animation: blink-odd 0.8s infinite;
}
.bulbs-even {
animation: blink-even 0.8s infinite;
}
/* Smooth custom scrollbars for prompt markdown */
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}

10
src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

137
src/utils/audio.ts Normal file
View file

@ -0,0 +1,137 @@
// Browser Web Audio API synthesizer for retro slot machine sounds
let audioCtx: AudioContext | null = null;
function getAudioContext(): AudioContext | null {
if (typeof window === "undefined") return null;
if (!audioCtx) {
const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;
if (AudioContextClass) {
audioCtx = new AudioContextClass();
}
}
// Resume if suspended (e.g. by browser autoplay restrictions)
if (audioCtx && audioCtx.state === "suspended") {
audioCtx.resume();
}
return audioCtx;
}
export function playTick() {
const ctx = getAudioContext();
if (!ctx) return;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = "sine";
osc.frequency.setValueAtTime(800, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(100, ctx.currentTime + 0.05);
gain.gain.setValueAtTime(0.08, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.05);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.05);
}
export function playLock() {
const ctx = getAudioContext();
if (!ctx) return;
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const osc2 = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = "triangle";
osc.frequency.setValueAtTime(523.25, now); // C5
osc.frequency.setValueAtTime(659.25, now + 0.08); // E5
osc2.type = "sine";
osc2.frequency.setValueAtTime(1046.50, now); // C6
osc2.frequency.exponentialRampToValueAtTime(1318.51, now + 0.15);
gain.gain.setValueAtTime(0.12, now);
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.2);
osc.connect(gain);
osc2.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc2.start();
osc.stop(now + 0.2);
osc2.stop(now + 0.2);
}
export function playLeverPull() {
const ctx = getAudioContext();
if (!ctx) return;
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = "sawtooth";
osc.frequency.setValueAtTime(150, now);
osc.frequency.linearRampToValueAtTime(400, now + 0.2);
gain.gain.setValueAtTime(0.1, now);
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.2);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(now + 0.2);
}
export function playJackpot() {
const ctx = getAudioContext();
if (!ctx) return;
const now = ctx.currentTime;
const notes = [261.63, 329.63, 392.00, 523.25, 659.25, 783.99, 1046.50]; // C Major scale notes
notes.forEach((freq, idx) => {
const noteTime = now + idx * 0.08;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = "square";
osc.frequency.setValueAtTime(freq, noteTime);
gain.gain.setValueAtTime(0.06, noteTime);
gain.gain.exponentialRampToValueAtTime(0.001, noteTime + 0.25);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(noteTime);
osc.stop(noteTime + 0.25);
});
}
export function playClick() {
const ctx = getAudioContext();
if (!ctx) return;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = "sine";
osc.frequency.setValueAtTime(600, ctx.currentTime);
gain.gain.setValueAtTime(0.05, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.1);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.1);
}

26
tsconfig.json Normal file
View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

22
vite.config.ts Normal file
View file

@ -0,0 +1,22 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig} from 'vite';
export default defineConfig(() => {
return {
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
watch: process.env.DISABLE_HMR === 'true' ? null : {},
},
};
});