diff --git a/.gitignore b/.gitignore index 43da9fc..307c2c0 100644 --- a/.gitignore +++ b/.gitignore @@ -72,6 +72,9 @@ logs/ *.pem *.key secrets/ +*-credentials.json +*-service-account.json +auron-ecommerce-*.json # ============================================================ # TEST diff --git a/frontend/app/(auth)/layout.tsx b/frontend/app/(auth)/layout.tsx new file mode 100644 index 0000000..583f33e --- /dev/null +++ b/frontend/app/(auth)/layout.tsx @@ -0,0 +1,138 @@ +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: { absolute: 'Auron' }, +} + +const DARK = 'oklch(0.085 0.01 50)' +const DARK_SURFACE = 'oklch(0.11 0.01 55)' +const GOLD = 'oklch(0.75 0.12 85)' +const TEXT = 'oklch(0.95 0.01 70)' +const MUTED = 'oklch(0.42 0.02 70)' + +export default function AuthLayout({ children }: { children: React.ReactNode }) { + return ( +
+ {/* ── Left panel — brand identity ─────────────────────── */} +
+ {/* Top — logo mark */} +
+
+ + Auron + +
+ + {/* Centre — large brand display */} +
+

+ Where +
+ excel +
+ lence +
+ meets +
+ elegance +

+ + {/* Decorative vertical line */} +
+
+ + {/* Bottom — tagline + accent */} +
+
+

+ Premium E-Commerce +

+
+ + {/* Corner accent — subtle glow */} +
+
+ + {/* ── Right panel — form container ────────────────────── */} +
+ {children} +
+
+ ) +} diff --git a/frontend/app/(auth)/login/login-form.tsx b/frontend/app/(auth)/login/login-form.tsx new file mode 100644 index 0000000..09d533a --- /dev/null +++ b/frontend/app/(auth)/login/login-form.tsx @@ -0,0 +1,243 @@ +'use client' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { loginSchema, type LoginFormValues } from '@/lib/validations/auth' +import { Form, FormField, FormItem, FormControl, FormMessage } from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { useRouter, useSearchParams } from 'next/navigation' +import { useState } from 'react' +import Link from 'next/link' + +const GOLD = 'oklch(0.75 0.12 85)' +const TEXT = 'oklch(0.95 0.01 70)' +const MUTED = 'oklch(0.42 0.02 70)' +const LABEL = 'oklch(0.52 0.02 70)' + +const fadeUp = (delay = 0): React.CSSProperties => ({ + animation: 'auron-fade-up 0.55s ease-out both', + animationDelay: `${delay}ms`, +}) + +export function LoginForm() { + const router = useRouter() + const searchParams = useSearchParams() + const callbackUrl = searchParams.get('callbackUrl') ?? '/' + const [isPending, setIsPending] = useState(false) + const [error, setError] = useState(null) + + const form = useForm({ + resolver: zodResolver(loginSchema), + defaultValues: { email: '', password: '' }, + }) + + async function onSubmit(values: LoginFormValues) { + setIsPending(true) + setError(null) + try { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(values), + }) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + setError( + data.message ?? data.error ?? + (res.status === 401 ? 'Invalid email or password' : 'Login failed') + ) + return + } + router.push(callbackUrl) + router.refresh() + } catch { + setError('Something went wrong. Please try again.') + } finally { + setIsPending(false) + } + } + + return ( +
+ + {/* Header */} +
+

+ Welcome back +

+

+ Sign in to Auron +

+
+ + {/* Email */} + ( + + + + + + + + )} + /> + + {/* Password */} + ( + +
+ + + Forgot? + +
+ + + + +
+ )} + /> + + {/* Error message */} + {error && ( +

+ {error} +

+ )} + + {/* Submit */} +
+ +
+ + {/* Register link */} +

+ Don't have an account?{' '} + + Create one + +

+ + + ) +} diff --git a/frontend/app/(auth)/login/page.tsx b/frontend/app/(auth)/login/page.tsx new file mode 100644 index 0000000..eec3a3f --- /dev/null +++ b/frontend/app/(auth)/login/page.tsx @@ -0,0 +1,13 @@ +import { Suspense } from 'react' +import type { Metadata } from 'next' +import { LoginForm } from './login-form' + +export const metadata: Metadata = { title: 'Sign In' } + +export default function LoginPage() { + return ( + + + + ) +} diff --git a/frontend/app/(auth)/register/page.tsx b/frontend/app/(auth)/register/page.tsx new file mode 100644 index 0000000..f76a642 --- /dev/null +++ b/frontend/app/(auth)/register/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from 'next' +import { RegisterForm } from './register-form' + +export const metadata: Metadata = { title: 'Create Account' } + +export default function RegisterPage() { + return +} diff --git a/frontend/app/(auth)/register/register-form.tsx b/frontend/app/(auth)/register/register-form.tsx new file mode 100644 index 0000000..6f23d83 --- /dev/null +++ b/frontend/app/(auth)/register/register-form.tsx @@ -0,0 +1,287 @@ +'use client' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { registerSchema, type RegisterFormValues } from '@/lib/validations/auth' +import { Form, FormField, FormItem, FormControl, FormMessage } from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { useRouter } from 'next/navigation' +import { useState } from 'react' +import Link from 'next/link' + +const GOLD = 'oklch(0.75 0.12 85)' +const TEXT = 'oklch(0.95 0.01 70)' +const MUTED = 'oklch(0.42 0.02 70)' +const LABEL = 'oklch(0.52 0.02 70)' + +const fadeUp = (delay = 0): React.CSSProperties => ({ + animation: 'auron-fade-up 0.55s ease-out both', + animationDelay: `${delay}ms`, +}) + +const labelStyle: React.CSSProperties = { + display: 'block', + fontSize: '0.65rem', + letterSpacing: '0.2em', + textTransform: 'uppercase', + color: LABEL, + marginBottom: '0.5rem', +} + +export function RegisterForm() { + const router = useRouter() + const [isPending, setIsPending] = useState(false) + const [error, setError] = useState(null) + + const form = useForm({ + resolver: zodResolver(registerSchema), + defaultValues: { + full_name: '', + email: '', + phone: '', + password: '', + confirm_password: '', + }, + }) + + async function onSubmit(values: RegisterFormValues) { + setIsPending(true) + setError(null) + try { + const { confirm_password: _, ...payload } = values + // Remove empty phone + if (!payload.phone) delete (payload as { phone?: string }).phone + + const res = await fetch('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + setError(data.message ?? data.error ?? 'Registration failed') + return + } + router.push('/') + router.refresh() + } catch { + setError('Something went wrong. Please try again.') + } finally { + setIsPending(false) + } + } + + return ( +
+ + {/* Header */} +
+

+ New here? +

+

+ Create your account +

+
+ + {/* Full name */} + ( + + + + + + + + )} + /> + + {/* Email */} + ( + + + + + + + + )} + /> + + {/* Phone */} + ( + + + + + + + + )} + /> + + {/* Password */} + ( + + + + + + + + )} + /> + + {/* Confirm password */} + ( + + + + + + + + )} + /> + + {/* Error */} + {error && ( +

+ {error} +

+ )} + + {/* Submit */} +
+ +
+ + {/* Login link */} +

+ Already have an account?{' '} + + Sign in + +

+ + + ) +} diff --git a/frontend/app/(shop)/cart/loading.tsx b/frontend/app/(shop)/cart/loading.tsx new file mode 100644 index 0000000..4f69072 --- /dev/null +++ b/frontend/app/(shop)/cart/loading.tsx @@ -0,0 +1,26 @@ +import { Skeleton } from '@/components/ui/skeleton' + +export default function Loading() { + return ( +
+ + +
+
+ {[1, 2].map(i => ( +
+ +
+ + + +
+ +
+ ))} +
+ +
+
+ ) +} diff --git a/frontend/app/(shop)/cart/page.tsx b/frontend/app/(shop)/cart/page.tsx new file mode 100644 index 0000000..031eda3 --- /dev/null +++ b/frontend/app/(shop)/cart/page.tsx @@ -0,0 +1,143 @@ +'use client' +import Link from 'next/link' +import { ShoppingBag } from 'lucide-react' +import { useCart } from '@/lib/hooks/use-cart' +import { CartItem } from '@/components/cart/cart-item' +import { CartSummary } from '@/components/cart/cart-summary' +import { Skeleton } from '@/components/ui/skeleton' + +const GOLD = 'oklch(0.75 0.12 85)' +const CHARCOAL = 'oklch(0.14 0.01 50)' +const MUTED = 'oklch(0.52 0 0)' +const CREAM = 'oklch(0.975 0.005 75)' + +function CartSkeleton() { + return ( +
+
+ {[1, 2, 3].map(i => ( +
+ +
+ + + +
+
+ + +
+
+ ))} +
+ +
+ ) +} + +export default function CartPage() { + const { data: cart, isLoading } = useCart() + + return ( +
+
+ {/* Header */} +
+

+ Your Selection +

+

+ Cart +

+
+ + {isLoading ? ( + + ) : !cart || cart.items.length === 0 ? ( + /* Empty state */ +
+
+ +
+
+

+ Your cart is empty +

+

+ Discover our curated collection and add your favourites. +

+
+ + Explore Collection + +
+ ) : ( + /* Cart with items */ +
+ {/* Items */} +
+ {cart.items.map(item => ( + + ))} +
+ + {/* Summary */} + +
+ )} +
+
+ ) +} diff --git a/frontend/app/(shop)/checkout/[orderId]/pay/page.tsx b/frontend/app/(shop)/checkout/[orderId]/pay/page.tsx new file mode 100644 index 0000000..1f0ca29 --- /dev/null +++ b/frontend/app/(shop)/checkout/[orderId]/pay/page.tsx @@ -0,0 +1,155 @@ +'use client' +import { useMemo } from 'react' +import { useParams } from 'next/navigation' +import { loadStripe } from '@stripe/stripe-js' +import { Elements } from '@stripe/react-stripe-js' +import { PaymentForm } from '@/components/checkout/payment-form' +import { usePaymentByOrder } from '@/lib/hooks/use-payment' +import { formatPrice } from '@/lib/utils' + +const GOLD = 'oklch(0.75 0.12 85)' +const CHARCOAL = 'oklch(0.14 0.01 50)' +const MUTED = 'oklch(0.52 0 0)' +const CREAM = 'oklch(0.975 0.005 75)' + +// Singleton — must be outside component to avoid re-creating on each render +const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!) + +const stripeAppearance = { + theme: 'flat' as const, + variables: { + colorPrimary: '#C9A96E', + colorBackground: '#ffffff', + colorText: '#1a1a14', + colorDanger: '#c0392b', + fontFamily: 'Geist, system-ui, sans-serif', + borderRadius: '3px', + spacingUnit: '4px', + }, + rules: { + '.Input': { + border: '1px solid #e2e0dc', + borderRadius: '3px', + padding: '11px 12px', + boxShadow: 'none', + }, + '.Input:focus': { + border: '1px solid #C9A96E', + boxShadow: 'none', + }, + '.Label': { + fontWeight: '500', + fontSize: '11px', + letterSpacing: '0.15em', + textTransform: 'uppercase', + color: '#7a7875', + }, + '.Tab': { + border: '1px solid #e2e0dc', + borderRadius: '3px', + }, + '.Tab--selected': { + border: '1px solid #C9A96E', + }, + }, +} + +function PulsingDot() { + return ( +
+ + {[0, 150, 300].map(delay => ( +
+ ))} +
+ ) +} + +export default function PayPage() { + const params = useParams() + const orderId = params.orderId as string + + const { data: payment, isLoading } = usePaymentByOrder(orderId) + + const elementsOptions = useMemo( + () => + payment?.client_secret + ? { clientSecret: payment.client_secret, appearance: stripeAppearance } + : null, + [payment?.client_secret], + ) + + return ( +
+
+ {/* Header */} +
+

+ Secure Payment +

+

+ Complete Your Order +

+ {payment && ( +

+ Order #{orderId.slice(-8).toUpperCase()} · {formatPrice(payment.amount)} +

+ )} +
+ +
+ {isLoading || !elementsOptions ? ( + /* Waiting for Kafka + Stripe PaymentIntent creation */ +
+ +
+

+ Preparing your payment +

+

+ This takes just a moment… +

+
+
+ ) : ( + + + + )} +
+ + {/* Security note */} +

+ Payments secured by Stripe · 256-bit SSL encryption +

+
+
+ ) +} diff --git a/frontend/app/(shop)/checkout/page.tsx b/frontend/app/(shop)/checkout/page.tsx new file mode 100644 index 0000000..5354696 --- /dev/null +++ b/frontend/app/(shop)/checkout/page.tsx @@ -0,0 +1,279 @@ +'use client' +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { useState } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod/v4' +import { useCart } from '@/lib/hooks/use-cart' +import { formatPrice } from '@/lib/utils' +import { Skeleton } from '@/components/ui/skeleton' + +const GOLD = 'oklch(0.75 0.12 85)' +const CHARCOAL = 'oklch(0.14 0.01 50)' +const MUTED = 'oklch(0.52 0 0)' +const CREAM = 'oklch(0.975 0.005 75)' + +const schema = z.object({ + shipping_name: z.string().min(2, 'Full name is required'), + shipping_address: z.string().min(5, 'Shipping address is required'), +}) +type FormValues = z.infer + +const inputStyle: React.CSSProperties = { + width: '100%', + height: '44px', + padding: '0 12px', + border: '1px solid oklch(0.88 0 0)', + borderRadius: '3px', + fontSize: '0.82rem', + color: CHARCOAL, + background: '#fff', + outline: 'none', + boxSizing: 'border-box', + transition: 'border-color 0.15s', +} + +const labelStyle: React.CSSProperties = { + display: 'block', + fontSize: '0.65rem', + fontWeight: 500, + letterSpacing: '0.15em', + textTransform: 'uppercase', + color: MUTED, + marginBottom: '0.4rem', +} + +export default function CheckoutPage() { + const router = useRouter() + const { data: cart, isLoading } = useCart() + const [serverError, setServerError] = useState(null) + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ resolver: zodResolver(schema) }) + + const onSubmit = async (values: FormValues) => { + setServerError(null) + const res = await fetch('/api/orders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(values), + }) + const data = await res.json() + if (!res.ok) { + setServerError(data?.error ?? 'Failed to place order. Please try again.') + return + } + const orderId = data?.data?.id + if (!orderId) { + setServerError('Order created but ID missing. Please check your orders.') + return + } + router.push(`/checkout/${orderId}/pay`) + } + + if (isLoading) { + return ( +
+ +
+
+ + +
+ +
+
+ ) + } + + if (!cart || cart.items.length === 0) { + return ( +
+

+ Your cart is empty +

+

+ Add items to your cart before checking out. +

+ + Shop Now + +
+ ) + } + + return ( +
+
+ {/* Header */} +
+

+ Secure Checkout +

+

+ Checkout +

+
+ +
+ {/* Left: Shipping form */} +
+
+

+ Shipping Information +

+ +
+
+ + (e.currentTarget.style.borderColor = GOLD)} + onBlur={e => (e.currentTarget.style.borderColor = errors.shipping_name ? 'oklch(0.6 0.18 25)' : 'oklch(0.88 0 0)')} + /> + {errors.shipping_name && ( +

+ {errors.shipping_name.message} +

+ )} +
+ +
+ +