feat: add admin authentication and management features
- Implemented Prisma schema with models for AdminUser, AppSettings, and Snapshot. - Created seed script to initialize the database with an admin user and JWT secret. - Developed admin login page with form handling and error management. - Added API routes for admin login, logout, change password, and JWT verification. - Integrated Stripe for payment intent management in admin orders. - Established middleware for protecting admin routes with JWT authentication. - Created Zustand stores for managing persona and snapshot states.
This commit is contained in:
@@ -1,12 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import { Elements } from '@stripe/react-stripe-js';
|
||||
import { useOrderStore, orderStore, type CheckoutStep } from '@/store/useOrderStore';
|
||||
import { ShippingStep } from './checkout/ShippingStep';
|
||||
import { PaymentStep } from './checkout/PaymentStep';
|
||||
import { ReviewStep } from './checkout/ReviewStep';
|
||||
import { ConfirmationStep } from './checkout/ConfirmationStep';
|
||||
|
||||
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
|
||||
|
||||
const STEPS: { id: CheckoutStep; label: string }[] = [
|
||||
{ id: 'shipping', label: 'Shipping' },
|
||||
{ id: 'payment', label: 'Payment' },
|
||||
@@ -19,6 +23,10 @@ function getStepIndex(step: CheckoutStep): number {
|
||||
|
||||
export function CheckoutOverlay() {
|
||||
const step = useOrderStore((s) => s.step);
|
||||
const clientSecret = useOrderStore((s) => s.payment.clientSecret);
|
||||
const paymentStatus = useOrderStore((s) => s.payment.status);
|
||||
const paymentError = useOrderStore((s) => s.payment.errorMessage);
|
||||
const [isCreatingIntent, setIsCreatingIntent] = useState(false);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
orderStore.getState().resetOrder();
|
||||
@@ -33,6 +41,16 @@ export function CheckoutOverlay() {
|
||||
}
|
||||
}, [step]);
|
||||
|
||||
// Create payment intent when entering the payment step (only once)
|
||||
useEffect(() => {
|
||||
if (step === 'payment' && !clientSecret && !isCreatingIntent && paymentStatus !== 'failed') {
|
||||
setIsCreatingIntent(true);
|
||||
orderStore.getState().createPaymentIntent().finally(() => {
|
||||
setIsCreatingIntent(false);
|
||||
});
|
||||
}
|
||||
}, [step, clientSecret, isCreatingIntent, paymentStatus]);
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
if (step === 'config') return;
|
||||
@@ -153,8 +171,55 @@ export function CheckoutOverlay() {
|
||||
{/* Step Content */}
|
||||
<div style={{ padding: '1.5rem' }}>
|
||||
{step === 'shipping' && <ShippingStep />}
|
||||
{step === 'payment' && <PaymentStep />}
|
||||
{step === 'review' && <ReviewStep />}
|
||||
{(step === 'payment' || step === 'review') && clientSecret ? (
|
||||
<Elements
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
clientSecret,
|
||||
appearance: {
|
||||
theme: 'stripe',
|
||||
variables: {
|
||||
colorPrimary: '#2563eb',
|
||||
borderRadius: '0.375rem',
|
||||
fontFamily: 'inherit',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Keep PaymentElement mounted (hidden) during review so confirmPayment works */}
|
||||
<div style={{ display: step === 'payment' ? 'block' : 'none' }}>
|
||||
<PaymentStep />
|
||||
</div>
|
||||
{step === 'review' && <ReviewStep />}
|
||||
</Elements>
|
||||
) : (step === 'payment' || step === 'review') && !clientSecret ? (
|
||||
<div style={{ textAlign: 'center', padding: '2rem', fontSize: '0.85rem' }}>
|
||||
{paymentStatus === 'failed' ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', alignItems: 'center' }}>
|
||||
<div style={{ color: '#dc2626' }}>{paymentError || 'Failed to initialize payment'}</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
orderStore.getState().setPayment({ status: 'idle', errorMessage: '' });
|
||||
}}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '0.375rem',
|
||||
border: '1px solid rgba(59, 130, 246, 0.5)',
|
||||
background: 'rgba(59, 130, 246, 0.08)',
|
||||
color: '#2563eb',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ color: '#94a3b8' }}>Initializing secure payment...</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{step === 'confirmed' && <ConfirmationStep />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,44 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { configStore, useConfigStore } from '@/store/useConfigStore';
|
||||
import { personaStore, usePersonaStore } from '@/store/usePersonaStore';
|
||||
import { PricingEngine } from './PricingEngine';
|
||||
|
||||
// Persona attire options with visual metadata
|
||||
const PERSONA_OPTIONS = [
|
||||
{
|
||||
id: 'none',
|
||||
label: 'Default',
|
||||
description: 'Original robot appearance',
|
||||
colors: { torso: '#3b82f6', legs: '#3b82f6' },
|
||||
},
|
||||
{
|
||||
id: 'emarati-kandura',
|
||||
label: 'Emarati Kandura',
|
||||
description: 'Traditional white robe attire',
|
||||
colors: { torso: '#f8fafc', legs: '#f8fafc' },
|
||||
},
|
||||
{
|
||||
id: 'industrial-vest',
|
||||
label: 'Industrial Vest',
|
||||
description: 'High-visibility safety vest',
|
||||
colors: { torso: '#f59e0b', legs: '#3b82f6' },
|
||||
},
|
||||
{
|
||||
id: 'business-suit',
|
||||
label: 'Business Suit',
|
||||
description: 'Professional navy suit',
|
||||
colors: { torso: '#1e293b', legs: '#1e293b' },
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function ConfigPanel() {
|
||||
const activeColors = useConfigStore((s) => s.activeColors);
|
||||
const activePersona = useConfigStore((s) => s.activePersonaAttire);
|
||||
const personas = usePersonaStore((s) => s.personas);
|
||||
|
||||
const colorsSectionRef = useRef<HTMLElement>(null);
|
||||
const personaSectionRef = useRef<HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
personaStore.getState().hydrate();
|
||||
}, []);
|
||||
|
||||
const handleColorChange = useCallback((key: 'primary' | 'secondary' | 'accent', value: string) => {
|
||||
configStore.getState().setColors({ [key]: value });
|
||||
}, []);
|
||||
@@ -80,7 +58,7 @@ export function ConfigPanel() {
|
||||
>
|
||||
<h3 style={sectionTitleStyle}>Persona Attire</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
{PERSONA_OPTIONS.map((persona) => {
|
||||
{personas.map((persona) => {
|
||||
const isActive = activePersona === persona.id;
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -1,89 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { orderStore, type PaymentInfo } from '@/store/useOrderStore';
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '0.6rem 0.75rem',
|
||||
borderRadius: '0.375rem',
|
||||
border: '1px solid rgba(0, 0, 0, 0.08)',
|
||||
background: 'rgba(255, 255, 255, 0.8)',
|
||||
color: '#1a1a2e',
|
||||
fontSize: '0.8rem',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s ease',
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 500,
|
||||
color: '#94a3b8',
|
||||
marginBottom: '0.3rem',
|
||||
display: 'block',
|
||||
};
|
||||
|
||||
const errorStyle: React.CSSProperties = {
|
||||
fontSize: '0.65rem',
|
||||
color: '#ef4444',
|
||||
marginTop: '0.2rem',
|
||||
};
|
||||
|
||||
interface FormErrors {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
function formatCardNumber(value: string): string {
|
||||
const digits = value.replace(/\D/g, '').slice(0, 16);
|
||||
return digits.replace(/(\d{4})(?=\d)/g, '$1 ');
|
||||
}
|
||||
|
||||
function formatExpiry(value: string): string {
|
||||
const digits = value.replace(/\D/g, '').slice(0, 4);
|
||||
if (digits.length > 2) {
|
||||
return `${digits.slice(0, 2)}/${digits.slice(2)}`;
|
||||
}
|
||||
return digits;
|
||||
}
|
||||
import { useState } from 'react';
|
||||
import { PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';
|
||||
import { orderStore } from '@/store/useOrderStore';
|
||||
|
||||
export function PaymentStep() {
|
||||
const [form, setForm] = useState<PaymentInfo>({
|
||||
cardNumber: '',
|
||||
expiry: '',
|
||||
cvv: '',
|
||||
nameOnCard: '',
|
||||
});
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleChange = useCallback((field: keyof PaymentInfo, value: string) => {
|
||||
let processed = value;
|
||||
if (field === 'cardNumber') processed = formatCardNumber(value);
|
||||
if (field === 'expiry') processed = formatExpiry(value);
|
||||
if (field === 'cvv') processed = value.replace(/\D/g, '').slice(0, 3);
|
||||
const handleSubmit = async () => {
|
||||
if (!stripe || !elements) return;
|
||||
|
||||
setForm((prev) => ({ ...prev, [field]: processed }));
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[field];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
setIsLoading(true);
|
||||
setErrorMsg('');
|
||||
|
||||
const validate = (): boolean => {
|
||||
const errs: FormErrors = {};
|
||||
const digits = form.cardNumber.replace(/\s/g, '');
|
||||
if (digits.length < 16) errs.cardNumber = 'Enter a valid 16-digit card number';
|
||||
if (form.expiry.length < 5) errs.expiry = 'Enter a valid expiry (MM/YY)';
|
||||
if (form.cvv.length < 3) errs.cvv = 'Enter a valid 3-digit CVV';
|
||||
if (!form.nameOnCard.trim()) errs.nameOnCard = 'Name on card is required';
|
||||
setErrors(errs);
|
||||
return Object.keys(errs).length === 0;
|
||||
};
|
||||
// Validate the payment element first
|
||||
const { error: submitError } = await elements.submit();
|
||||
if (submitError) {
|
||||
setErrorMsg(submitError.message || 'Validation failed');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!validate()) return;
|
||||
orderStore.getState().setPayment(form);
|
||||
// Move to review — actual confirmation happens on "Place Order"
|
||||
orderStore.getState().setPayment({ status: 'idle' });
|
||||
orderStore.getState().setStep('review');
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -93,89 +37,48 @@ export function PaymentStep() {
|
||||
</h3>
|
||||
|
||||
<div style={{
|
||||
padding: '0.6rem 0.75rem',
|
||||
borderRadius: '0.375rem',
|
||||
background: 'rgba(245, 158, 11, 0.06)',
|
||||
border: '1px solid rgba(245, 158, 11, 0.15)',
|
||||
fontSize: '0.7rem',
|
||||
color: '#d97706',
|
||||
padding: '1rem',
|
||||
borderRadius: '0.5rem',
|
||||
background: '#fff',
|
||||
border: '1px solid rgba(0, 0, 0, 0.08)',
|
||||
}}>
|
||||
This is a demo checkout. No real payment will be processed.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Card Number</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.cardNumber}
|
||||
onChange={(e) => handleChange('cardNumber', e.target.value)}
|
||||
placeholder="4242 4242 4242 4242"
|
||||
style={{ ...inputStyle, fontFamily: 'monospace', letterSpacing: '0.1em', borderColor: errors.cardNumber ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)' }}
|
||||
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
|
||||
onBlur={(e) => { e.currentTarget.style.borderColor = errors.cardNumber ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
|
||||
<PaymentElement
|
||||
options={{
|
||||
layout: 'tabs',
|
||||
}}
|
||||
/>
|
||||
{errors.cardNumber && <div style={errorStyle}>{errors.cardNumber}</div>}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Expiry Date</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.expiry}
|
||||
onChange={(e) => handleChange('expiry', e.target.value)}
|
||||
placeholder="MM/YY"
|
||||
style={{ ...inputStyle, fontFamily: 'monospace', borderColor: errors.expiry ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)' }}
|
||||
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
|
||||
onBlur={(e) => { e.currentTarget.style.borderColor = errors.expiry ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
|
||||
/>
|
||||
{errors.expiry && <div style={errorStyle}>{errors.expiry}</div>}
|
||||
{errorMsg && (
|
||||
<div style={{
|
||||
padding: '0.6rem 0.75rem',
|
||||
borderRadius: '0.375rem',
|
||||
background: 'rgba(239, 68, 68, 0.06)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.15)',
|
||||
fontSize: '0.75rem',
|
||||
color: '#dc2626',
|
||||
}}>
|
||||
{errorMsg}
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>CVV</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.cvv}
|
||||
onChange={(e) => handleChange('cvv', e.target.value)}
|
||||
placeholder="123"
|
||||
style={{ ...inputStyle, fontFamily: 'monospace', borderColor: errors.cvv ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)' }}
|
||||
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
|
||||
onBlur={(e) => { e.currentTarget.style.borderColor = errors.cvv ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
|
||||
/>
|
||||
{errors.cvv && <div style={errorStyle}>{errors.cvv}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Name on Card</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.nameOnCard}
|
||||
onChange={(e) => handleChange('nameOnCard', e.target.value)}
|
||||
placeholder="John Doe"
|
||||
style={{ ...inputStyle, borderColor: errors.nameOnCard ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)' }}
|
||||
onFocus={(e) => { e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.5)'; }}
|
||||
onBlur={(e) => { e.currentTarget.style.borderColor = errors.nameOnCard ? 'rgba(239, 68, 68, 0.4)' : 'rgba(0, 0, 0, 0.08)'; }}
|
||||
/>
|
||||
{errors.nameOnCard && <div style={errorStyle}>{errors.nameOnCard}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!stripe || isLoading}
|
||||
style={{
|
||||
marginTop: '0.5rem',
|
||||
padding: '0.75rem',
|
||||
borderRadius: '0.375rem',
|
||||
border: '1px solid rgba(59, 130, 246, 0.5)',
|
||||
background: 'rgba(59, 130, 246, 0.08)',
|
||||
background: isLoading ? 'rgba(59, 130, 246, 0.15)' : 'rgba(59, 130, 246, 0.08)',
|
||||
color: '#2563eb',
|
||||
cursor: 'pointer',
|
||||
cursor: isLoading ? 'wait' : 'pointer',
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 600,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
Review Order
|
||||
{isLoading ? 'Validating...' : 'Review Order'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useStripe, useElements } from '@stripe/react-stripe-js';
|
||||
import { useOrderStore, orderStore } from '@/store/useOrderStore';
|
||||
|
||||
function formatAED(price: number): string {
|
||||
@@ -8,16 +9,41 @@ function formatAED(price: number): string {
|
||||
}
|
||||
|
||||
export function ReviewStep() {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const shipping = useOrderStore((s) => s.shipping);
|
||||
const orderTotal = useOrderStore((s) => s.orderTotal);
|
||||
const personaSummary = useOrderStore((s) => s.personaSummary);
|
||||
const colorSummary = useOrderStore((s) => s.colorSummary);
|
||||
const clientSecret = useOrderStore((s) => s.payment.clientSecret);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
const handlePlaceOrder = async () => {
|
||||
if (!stripe || !elements || !clientSecret) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
// Simulate payment processing delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
setErrorMsg('');
|
||||
|
||||
const { error } = await stripe.confirmPayment({
|
||||
elements,
|
||||
clientSecret,
|
||||
confirmParams: {
|
||||
return_url: `${window.location.origin}?order=confirmed`,
|
||||
receipt_email: shipping.email,
|
||||
},
|
||||
redirect: 'if_required',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setErrorMsg(error.message || 'Payment failed. Please try again.');
|
||||
orderStore.getState().setPayment({ status: 'failed', errorMessage: error.message || '' });
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Payment succeeded
|
||||
orderStore.getState().setPayment({ status: 'succeeded' });
|
||||
orderStore.getState().placeOrder();
|
||||
};
|
||||
|
||||
@@ -69,10 +95,24 @@ export function ReviewStep() {
|
||||
</div>
|
||||
</SummarySection>
|
||||
|
||||
{/* Error message */}
|
||||
{errorMsg && (
|
||||
<div style={{
|
||||
padding: '0.6rem 0.75rem',
|
||||
borderRadius: '0.375rem',
|
||||
background: 'rgba(239, 68, 68, 0.06)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.15)',
|
||||
fontSize: '0.75rem',
|
||||
color: '#dc2626',
|
||||
}}>
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Place Order Button */}
|
||||
<button
|
||||
onClick={handlePlaceOrder}
|
||||
disabled={isProcessing}
|
||||
disabled={isProcessing || !stripe}
|
||||
style={{
|
||||
marginTop: '0.25rem',
|
||||
padding: '0.85rem',
|
||||
|
||||
Reference in New Issue
Block a user