2 nextjs scaffold with store mode shop and admin session experiment wiring event emission v1 (#17)

* chore: cleaning gitignore

* formating and env documentation

* feat: context switching of hotel/airline depndent on env var via middleware

* fixed alignment and building

* wrong file

* prods

* fixed applying style

* better session cookie management

* tentative session storage with maybe using airtable

* migrated api of ingestion

* events and products apge

* fixing build

* 13 create outline for research paper draft (#18)

* updated outline for paper from issue

* extra paper sections and some formalization of series data

* algorithms and acknowledgements

* updated outline for paper from issue

* upadted text formating

* event unification

* refactor tracking to ues callbacks instead of refs

* implement a pricing display api with session passing

* moved middleware to proxy according to new changes in Nextjs

* refactoed kafka ingestion to go via backend not web-db

* Refactor docker-compose services to use individual Dockerfiles (#20)

* Initial plan

* Refactor services into individual Dockerfiles

Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>

* Add EXPOSE directives to all Dockerfiles with port documentation

Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>

* fixing small bugs and adding exepriments to tracking

* added some doc
This commit is contained in:
Daniel Alves Rösel
2025-11-13 18:07:27 +01:00
committed by GitHub
parent 7ece6e82cb
commit 37b2099ee0
50 changed files with 2865 additions and 446 deletions

View File

@@ -0,0 +1,20 @@
import { ReactNode, ButtonHTMLAttributes } from 'react';
type BtnVariant = 'primary' | 'secondary';
interface BtnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: BtnVariant;
children: ReactNode;
fullWidth?: boolean;
}
export default function Button({ variant = 'primary', children, fullWidth, className = '', ...props }: BtnProps) {
const baseClass = variant === 'primary' ? 'btn-primary' : 'btn-secondary';
const widthClass = fullWidth ? 'w-full' : '';
return (
<button className={`${baseClass} ${widthClass} ${className}`.trim()} {...props}>
{children}
</button>
);
}

View File

@@ -0,0 +1,7 @@
import { InputHTMLAttributes } from 'react';
interface DateInpProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {}
export default function DateInput({ className = '', ...props }: DateInpProps) {
return <input type="date" className={`input-field ${className}`.trim()} {...props} />;
}

View File

@@ -0,0 +1,83 @@
'use client';
import { ReactNode, useState, useRef, useEffect } from 'react';
interface DropdownProps {
label: string;
children: ReactNode;
}
export default function Dropdown({ label, children }: DropdownProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen(!open)}
className="input-field flex justify-between items-center w-full"
>
<span>{label}</span>
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="absolute z-10 mt-2 w-full bg-white border border-gray-200 rounded-lg shadow-lg p-4">
{children}
</div>
)}
</div>
);
}
interface CounterProps {
label: string;
sublabel?: string;
value: number;
min?: number;
max?: number;
onChange: (val: number) => void;
}
export function DropdownCounter({ label, sublabel, value, min = 0, max = 99, onChange }: CounterProps) {
return (
<div className="flex justify-between items-center py-3 border-b border-gray-100 last:border-b-0">
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">{label}</span>
{sublabel && <span className="text-xs text-gray-500 mt-0.5">{sublabel}</span>}
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => onChange(Math.max(min, value - 1))}
disabled={value <= min}
className="w-9 h-9 rounded-full border-2 border-gray-300 flex items-center justify-center hover:border-blue-500 hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:border-gray-300 disabled:hover:bg-transparent transition-colors text-lg font-medium text-gray-700"
>
</button>
<span className="w-10 text-center font-semibold text-gray-900">{value}</span>
<button
type="button"
onClick={() => onChange(Math.min(max, value + 1))}
disabled={value >= max}
className="w-9 h-9 rounded-full border-2 border-gray-300 flex items-center justify-center hover:border-blue-500 hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:border-gray-300 disabled:hover:bg-transparent transition-colors text-lg font-medium text-gray-700"
>
+
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { InputHTMLAttributes, ReactNode } from 'react';
interface InpProps extends InputHTMLAttributes<HTMLInputElement> {
icon?: ReactNode;
}
export default function Input({ icon, className = '', style, ...props }: InpProps) {
const padClass = icon ? 'pl-10' : '';
// Fallback if a custom CSS rule still overrides Tailwind
const mergedStyle = icon ? { paddingInlineStart: '2.5rem', ...style } : style;
return (
<div className="relative">
{icon && (
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-gray-400 z-10"
>
{icon}
</div>
)}
<input
className={`input-field ${className} ${padClass}`}
style={mergedStyle}
{...props}
/>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { ReactNode, LabelHTMLAttributes } from 'react';
interface LblProps extends LabelHTMLAttributes<HTMLLabelElement> {
children: ReactNode;
}
export default function Label({ children, className = '', ...props }: LblProps) {
return (
<label className={`block text-sm font-medium mb-2 ${className}`.trim()} {...props}>
{children}
</label>
);
}

View File

@@ -0,0 +1,48 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import type { EventName } from '@/lib/events';
const dispatchInteraction = (eventName: EventName, metadata?: Record<string, unknown>) => {
const e = new CustomEvent('definedInteraction', {
detail: { eventName, metadata },
});
document.dispatchEvent(e);
};
const NavLink = ({ href, children }: { href: string; children: React.ReactNode }) => {
const path = usePathname();
const isActive = path === href;
return (
<Link
href={href}
className={`px-4 py-2 rounded-md transition-colors ${
isActive
? 'bg-[var(--accent-primary)] text-white font-semibold'
: 'hover:bg-[var(--accent-primary-light)] text-[var(--text-primary)]'
}`}
>
{children}
</Link>
);
};
export default function Navigation() {
return (
<nav className="bg-[var(--bg-primary)] border-b border-gray-200 shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex items-center space-x-1">
<NavLink href="/">Home</NavLink>
<NavLink href="/products">Products</NavLink>
<NavLink href="/search">Search</NavLink>
<NavLink href="/cart">Cart</NavLink>
<NavLink href="/checkout">Checkout</NavLink>
</div>
</div>
</div>
</nav>
);
}

View File

@@ -0,0 +1,136 @@
'use client';
import { useEffect, useState, useRef } from 'react';
interface PriceDisplayProps {
productId: string;
className?: string;
perNight?: boolean;
}
interface PricingData {
price: number;
currency: string;
cachedAt: string;
}
interface SessionData {
sessionId: string;
experimentId?: string;
}
const fetchSession = async (): Promise<SessionData> => {
try {
const res = await fetch('/api/session');
const data = await res.json();
return {
sessionId: data.sessionId || '',
experimentId: data.experimentId || '',
};
} catch (err) {
console.error('failed to fetch session:', err);
return { sessionId: '', experimentId: '' };
}
};
const formatPrice = (price: number, currency: string) => {
return new Intl.NumberFormat('en-US', { // like an std localization
style: 'currency',
currency,
}).format(price);
};
const isCacheStale = (cachedAt: string, thresholdMs = 60000) => {
const cacheTime = new Date(cachedAt).getTime();
const now = Date.now();
return now - cacheTime > thresholdMs;
};
export default function PriceDisplay({
productId,
className = '',
perNight = false,
}: PriceDisplayProps) {
const sessionRef = useRef<SessionData | null>(null);
const [data, setData] = useState<PricingData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const initAndFetch = async () => {
setLoading(true);
setError(null);
try {
// fetch session if not already loaded
if (!sessionRef.current) {
sessionRef.current = await fetchSession();
}
const { sessionId, experimentId } = sessionRef.current;
if (!sessionId) {
setError('Invalid session');
setLoading(false);
return;
}
const params = new URLSearchParams({
productId,
sessionId,
experimentId: experimentId || '',
});
const res = await fetch(`/api/pricing?${params.toString()}`);
if (!res.ok) {
throw new Error(`Failed to fetch price: ${res.status}`);
}
const pricingData: PricingData = await res.json();
setData(pricingData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
initAndFetch();
}, [productId]);
if (loading) {
return (
<div className={`price-loading ${className}`}>
<div className="spinner-border animate-spin inline-block w-4 h-4 border-2 rounded-full" role="status">
<span className="sr-only">Loading...</span>
</div>
</div>
);
}
if (error || !data) {
return (
<div className={`price-error ${className}`}>
<span className="text-red-500 text-sm">Price unavailable</span>
</div>
);
}
const isStale = isCacheStale(data.cachedAt);
const formattedPrice = formatPrice(data.price, data.currency);
return (
<div className={`price-display ${className}`}>
<div className="price-amount">
{formattedPrice}
{perNight && <span className="text-xs ml-1">/night</span>}
</div>
{isStale && (
<span className="price-stale text-xs text-yellow-600" title={`Cached at ${data.cachedAt}`}>
prices may be outdated
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,33 @@
'use client';
interface RadioOpt<T extends string> {
value: T;
label: string;
}
interface RadioGrpProps<T extends string> {
name: string;
options: RadioOpt<T>[];
value: T;
onChange: (val: T) => void;
}
export default function RadioGroup<T extends string>({ name, options, value, onChange }: RadioGrpProps<T>) {
return (
<div className="flex gap-4">
{options.map((opt) => (
<label key={opt.value} className="flex items-center cursor-pointer">
<input
type="radio"
name={name}
value={opt.value}
checked={value === opt.value}
onChange={(e) => onChange(e.target.value as T)}
className="mr-2"
/>
<span className="text-sm">{opt.label}</span>
</label>
))}
</div>
);
}

View File

@@ -0,0 +1,7 @@
export { default as Button } from './Button';
export { default as Label } from './Label';
export { default as Input } from './Input';
export { default as DateInput } from './DateInput';
export { default as RadioGroup } from './RadioGroup';
export { default as Dropdown, DropdownCounter } from './Dropdown';
export { default as Navigation } from './Navigation';