mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-07-16 01:53:37 +00:00
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:
committed by
GitHub
parent
7ece6e82cb
commit
37b2099ee0
20
web/src/components/ui/Button.tsx
Normal file
20
web/src/components/ui/Button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
web/src/components/ui/DateInput.tsx
Normal file
7
web/src/components/ui/DateInput.tsx
Normal 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} />;
|
||||
}
|
||||
83
web/src/components/ui/Dropdown.tsx
Normal file
83
web/src/components/ui/Dropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
web/src/components/ui/Input.tsx
Normal file
29
web/src/components/ui/Input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
web/src/components/ui/Label.tsx
Normal file
13
web/src/components/ui/Label.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
web/src/components/ui/Navigation.tsx
Normal file
48
web/src/components/ui/Navigation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
136
web/src/components/ui/PriceDisplay.tsx
Normal file
136
web/src/components/ui/PriceDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
web/src/components/ui/RadioGroup.tsx
Normal file
33
web/src/components/ui/RadioGroup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
web/src/components/ui/index.ts
Normal file
7
web/src/components/ui/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user