Initial commit: redefined-designs storefront

This commit is contained in:
2026-08-13 21:07:54 +00:00
commit 9be4986dd3
38 changed files with 2121 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
import { useEffect, useState, useCallback } from 'react';
import { Layout, Typography, Switch, Space, Row, Col, Spin, Button, theme } from 'antd';
import { Link } from 'react-router-dom';
import { Item, SiteConfig, fetchItems, fetchConfig } from './api';
import ItemCard from './components/ItemCard';
import { useThemeMode } from './theme/ThemeContext';
import { useCustomerAuth } from './customer/CustomerAuthContext';
const { Header, Content, Footer } = Layout;
const { Title } = Typography;
export default function App() {
const [items, setItems] = useState<Item[]>([]);
const [config, setConfig] = useState<SiteConfig | null>(null);
const { mode, toggle } = useThemeMode();
const { customer } = useCustomerAuth();
const { token } = theme.useToken();
const load = useCallback(() => {
fetchItems().then(setItems);
}, []);
useEffect(() => {
load();
fetchConfig().then(setConfig);
}, [load]);
return (
<Layout style={{ minHeight: '100vh' }}>
<Header
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: token.colorBgContainer,
borderBottom: `1px solid ${token.colorBorderSecondary}`
}}
>
<Title level={3} style={{ color: token.colorText, margin: 0 }}>Redefined Designs</Title>
<Space>
<Switch checked={mode === 'dark'} onChange={toggle} checkedChildren="Dark" unCheckedChildren="Light" />
{customer ? (
<Link to="/account"><Button>My Account</Button></Link>
) : (
<>
<Link to="/login"><Button>Log in</Button></Link>
<Link to="/register"><Button type="primary">Sign up</Button></Link>
</>
)}
</Space>
</Header>
<Content style={{ padding: 24 }}>
{!config ? <Spin /> : (
<Row gutter={[20, 20]}>
{items.map(item => (
<Col key={item.id} xs={24} sm={12} md={8} lg={6}>
<ItemCard item={item} config={config} onPurchased={load} />
</Col>
))}
</Row>
)}
</Content>
<Footer style={{ textAlign: 'center', background: token.colorBgContainer }}>
<Link to="/privacy">Privacy Policy</Link>
</Footer>
</Layout>
);
}
+221
View File
@@ -0,0 +1,221 @@
import { useEffect, useState } from 'react';
import {
Layout, Table, Button, Form, Input, InputNumber, Upload, Modal,
Space, Tag, Typography, Switch, message, Image as AntImage, theme, Tabs
} from 'antd';
import { UploadOutlined, DeleteOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload/interface';
import MDEditor from '@uiw/react-md-editor';
import '@uiw/react-md-editor/markdown-editor.css';
import '@uiw/react-markdown-preview/markdown.css';
import { Item, fetchAdminItems, saveItem, deleteItem, deleteItemImage, markSold, markAvailable } from '../api';
import { useThemeMode } from '../theme/ThemeContext';
import Customers from './Customers';
const { Header, Content } = Layout;
const { Title } = Typography;
function Inventory() {
const [items, setItems] = useState<Item[]>([]);
const [modalOpen, setModalOpen] = useState(false);
const [editingItem, setEditingItem] = useState<Item | null>(null);
const [form] = Form.useForm();
const [fileList, setFileList] = useState<UploadFile[]>([]);
const { mode } = useThemeMode();
const load = () => fetchAdminItems().then(setItems);
useEffect(() => { load(); }, []);
function openNew() {
setEditingItem(null);
form.resetFields();
setFileList([]);
setModalOpen(true);
}
function openEdit(item: Item) {
setEditingItem(item);
form.setFieldsValue({
name: item.name,
description: item.description,
price: item.price_cents / 100
});
setFileList([]);
setModalOpen(true);
}
async function handleOk() {
const values = await form.validateFields();
const fd = new FormData();
fd.append('name', values.name);
fd.append('description', values.description || '');
fd.append('price', String(values.price));
fileList.forEach(f => {
if (f.originFileObj) fd.append('images', f.originFileObj as File);
});
await saveItem(editingItem?.id ?? null, fd);
message.success(editingItem ? 'Item updated' : 'Item added');
setModalOpen(false);
load();
}
async function handleDelete(id: number) {
await deleteItem(id);
message.success('Item deleted');
load();
}
async function handleDeleteImage(itemId: number, imageId: number) {
await deleteItemImage(itemId, imageId);
message.success('Image removed');
load();
setEditingItem(prev => prev && prev.id === itemId
? { ...prev, images: prev.images.filter(img => img.id !== imageId) }
: prev);
}
const columns = [
{
title: 'Image',
dataIndex: 'images',
render: (images: Item['images']) =>
images[0] ? (
<span style={{ position: 'relative', display: 'inline-block' }}>
<img src={images[0].image_path} style={{ width: 60 }} />
{images.length > 1 && (
<Tag style={{ position: 'absolute', bottom: -4, right: -8, fontSize: 10 }}>
+{images.length - 1}
</Tag>
)}
</span>
) : null
},
{ title: 'Name', dataIndex: 'name' },
{
title: 'Price',
dataIndex: 'price_cents',
render: (v: number) => `$${(v / 100).toFixed(2)}`
},
{
title: 'Status',
dataIndex: 'status',
render: (status: string) => (
<Tag color={status === 'sold' ? 'red' : status === 'reserved' ? 'orange' : 'green'}>
{status.toUpperCase()}
</Tag>
)
},
{
title: 'Actions',
render: (_: unknown, item: Item) => (
<Space>
<Button size="small" onClick={() => openEdit(item)}>Edit</Button>
<Button size="small" danger onClick={() => handleDelete(item.id)}>Delete</Button>
{item.status !== 'sold'
? <Button size="small" onClick={() => markSold(item.id).then(load)}>Mark Sold</Button>
: <Button size="small" onClick={() => markAvailable(item.id).then(load)}>Mark Available</Button>}
</Space>
)
}
];
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Title level={4} style={{ margin: 0 }}>Inventory</Title>
<Button type="primary" onClick={openNew}>Add Item</Button>
</div>
<Table rowKey="id" dataSource={items} columns={columns} />
<Modal
title={editingItem ? 'Edit Item' : 'Add Item'}
open={modalOpen}
onOk={handleOk}
onCancel={() => setModalOpen(false)}
destroyOnClose
width={720}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="Name" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item
name="description"
label="Description (Markdown supported)"
getValueFromEvent={(value) => value}
>
<div data-color-mode={mode}>
<MDEditor height={220} preview="live" />
</div>
</Form.Item>
<Form.Item name="price" label="Price (USD)" rules={[{ required: true }]}>
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
</Form.Item>
{editingItem && editingItem.images.length > 0 && (
<Form.Item label="Existing Images (front / back / etc.)">
<Space wrap>
{editingItem.images.map(img => (
<div key={img.id} style={{ position: 'relative' }}>
<AntImage src={img.image_path} width={80} height={80} style={{ objectFit: 'cover' }} />
<Button
size="small"
danger
icon={<DeleteOutlined />}
style={{ position: 'absolute', top: 0, right: 0 }}
onClick={() => handleDeleteImage(editingItem.id, img.id)}
/>
</div>
))}
</Space>
</Form.Item>
)}
<Form.Item label={editingItem ? 'Add More Images' : 'Images (front, back, etc.)'}>
<Upload
fileList={fileList}
beforeUpload={() => false}
onChange={({ fileList }) => setFileList(fileList.slice(-6))}
maxCount={6}
multiple
listType="picture-card"
>
<div><UploadOutlined /><div style={{ marginTop: 8 }}>Upload</div></div>
</Upload>
</Form.Item>
</Form>
</Modal>
</div>
);
}
export default function Admin() {
const { mode, toggle } = useThemeMode();
const { token } = theme.useToken();
return (
<Layout style={{ minHeight: '100vh' }}>
<Header
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: token.colorBgContainer,
borderBottom: `1px solid ${token.colorBorderSecondary}`
}}
>
<Title level={3} style={{ color: token.colorText, margin: 0 }}>Admin</Title>
<Switch checked={mode === 'dark'} onChange={toggle} checkedChildren="Dark" unCheckedChildren="Light" />
</Header>
<Content style={{ padding: 24 }}>
<Tabs
defaultActiveKey="inventory"
items={[
{ key: 'inventory', label: 'Inventory', children: <Inventory /> },
{ key: 'customers', label: 'Customers', children: <Customers /> }
]}
/>
</Content>
</Layout>
);
}
+150
View File
@@ -0,0 +1,150 @@
import { useEffect, useState } from 'react';
import { Table, Drawer, Descriptions, Tag, Typography, Spin, Empty } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { fetchCustomers, fetchCustomerDetail, CustomerSummary, CustomerDetail } from './adminCustomersApi';
const { Title } = Typography;
export default function Customers() {
const [customers, setCustomers] = useState<CustomerSummary[]>([]);
const [loading, setLoading] = useState(true);
const [detail, setDetail] = useState<CustomerDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
useEffect(() => {
fetchCustomers().then(rows => { setCustomers(rows); setLoading(false); });
}, []);
async function openDetail(id: number) {
setDrawerOpen(true);
setDetailLoading(true);
const data = await fetchCustomerDetail(id);
setDetail(data);
setDetailLoading(false);
}
const columns: ColumnsType<CustomerSummary> = [
{
title: 'Customer',
dataIndex: 'email',
sorter: (a, b) => a.email.localeCompare(b.email),
render: (email: string, row: CustomerSummary) => (
<div>
<div>{row.name || <span style={{ opacity: 0.5 }}>No name</span>}</div>
<div style={{ fontSize: 12, opacity: 0.65 }}>{email}</div>
</div>
)
},
{
title: 'Verified',
dataIndex: 'email_verified',
filters: [{ text: 'Verified', value: true }, { text: 'Unverified', value: false }],
onFilter: (value, row) => row.email_verified === value,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? 'Verified' : 'Unverified'}</Tag>
},
{
title: 'Subscribed',
dataIndex: 'marketing_consent',
filters: [{ text: 'Subscribed', value: true }, { text: 'Not subscribed', value: false }],
onFilter: (value, row) => row.marketing_consent === value,
render: (v: boolean) => <Tag color={v ? 'blue' : 'default'}>{v ? 'Yes' : 'No'}</Tag>
},
{
title: 'Orders',
dataIndex: 'order_count',
sorter: (a, b) => a.order_count - b.order_count,
defaultSortOrder: 'descend'
},
{
title: 'Total Spent',
dataIndex: 'total_spent_cents',
sorter: (a, b) => a.total_spent_cents - b.total_spent_cents,
render: (v: number) => `$${(v / 100).toFixed(2)}`
},
{
title: 'Last Order',
dataIndex: 'last_order_at',
sorter: (a, b) => new Date(a.last_order_at || 0).getTime() - new Date(b.last_order_at || 0).getTime(),
render: (v: string | null) => (v ? new Date(v).toLocaleDateString() : '—')
},
{
title: 'Joined',
dataIndex: 'created_at',
sorter: (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
render: (v: string) => new Date(v).toLocaleDateString()
}
];
return (
<div>
<Title level={4}>Customers</Title>
<Table
rowKey="id"
loading={loading}
dataSource={customers}
columns={columns}
onRow={row => ({ onClick: () => openDetail(row.id), style: { cursor: 'pointer' } })}
pagination={{ pageSize: 10 }}
/>
<Drawer
title={detail?.customer.name || detail?.customer.email || 'Customer'}
open={drawerOpen}
onClose={() => { setDrawerOpen(false); setDetail(null); }}
width={480}
>
{detailLoading || !detail ? (
<Spin />
) : (
<>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="Email">{detail.customer.email}</Descriptions.Item>
<Descriptions.Item label="Verified">
<Tag color={detail.customer.email_verified ? 'green' : 'default'}>
{detail.customer.email_verified ? 'Verified' : 'Unverified'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Marketing consent">
<Tag color={detail.customer.marketing_consent ? 'blue' : 'default'}>
{detail.customer.marketing_consent ? 'Subscribed' : 'Not subscribed'}
</Tag>
{detail.customer.marketing_consent_at && (
<div style={{ fontSize: 12, opacity: 0.65, marginTop: 4 }}>
since {new Date(detail.customer.marketing_consent_at).toLocaleDateString()}
</div>
)}
</Descriptions.Item>
<Descriptions.Item label="Joined">
{new Date(detail.customer.created_at).toLocaleDateString()}
</Descriptions.Item>
</Descriptions>
<Title level={5} style={{ marginTop: 24 }}>Order History</Title>
{detail.orders.length === 0 ? (
<Empty description="No orders yet" />
) : (
<Table
rowKey="id"
size="small"
dataSource={detail.orders}
pagination={false}
columns={[
{ title: 'Item', dataIndex: 'item_name' },
{ title: 'Amount', dataIndex: 'amount_cents', render: (v: number) => `$${(v / 100).toFixed(2)}` },
{
title: 'Processor',
dataIndex: 'processor',
render: (v: string) => <Tag>{v}</Tag>
},
{ title: 'Status', dataIndex: 'status' },
{ title: 'Date', dataIndex: 'created_at', render: (v: string) => new Date(v).toLocaleDateString() }
]}
/>
)}
</>
)}
</Drawer>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
export interface CustomerSummary {
id: number;
email: string;
name: string | null;
email_verified: boolean;
marketing_consent: boolean;
created_at: string;
order_count: number;
total_spent_cents: number;
last_order_at: string | null;
}
export interface CustomerOrder {
id: number;
processor: string;
processor_order_id: string | null;
amount_cents: number;
status: string;
created_at: string;
item_name: string;
}
export interface CustomerDetail {
customer: {
id: number;
email: string;
name: string | null;
email_verified: boolean;
marketing_consent: boolean;
marketing_consent_at: string | null;
created_at: string;
};
orders: CustomerOrder[];
}
export async function fetchCustomers(): Promise<CustomerSummary[]> {
const res = await fetch('/api/admin/customers');
return res.json();
}
export async function fetchCustomerDetail(id: number): Promise<CustomerDetail> {
const res = await fetch(`/api/admin/customers/${id}`);
return res.json();
}
+83
View File
@@ -0,0 +1,83 @@
export interface ItemImage {
id: number;
image_path: string;
sort_order: number;
}
export interface Item {
id: number;
name: string;
description: string | null;
price_cents: number;
images: ItemImage[];
status: 'available' | 'reserved' | 'sold';
}
export interface SiteConfig {
paypalClientId: string | null;
demoMode: boolean;
currency: string;
}
export async function fetchConfig(): Promise<SiteConfig> {
const res = await fetch('/api/config');
return res.json();
}
export async function fetchItems(): Promise<Item[]> {
const res = await fetch('/api/items');
return res.json();
}
export async function fetchAdminItems(): Promise<Item[]> {
const res = await fetch('/api/admin/items');
return res.json();
}
export async function saveItem(id: number | null, formData: FormData): Promise<Item> {
const url = id ? `/api/admin/items/${id}` : '/api/admin/items';
const res = await fetch(url, { method: id ? 'PUT' : 'POST', body: formData });
return res.json();
}
export async function deleteItem(id: number): Promise<void> {
await fetch(`/api/admin/items/${id}`, { method: 'DELETE' });
}
export async function deleteItemImage(itemId: number, imageId: number): Promise<void> {
await fetch(`/api/admin/items/${itemId}/images/${imageId}`, { method: 'DELETE' });
}
export async function markSold(id: number): Promise<Item> {
const res = await fetch(`/api/admin/items/${id}/mark-sold`, { method: 'POST' });
return res.json();
}
export async function markAvailable(id: number): Promise<Item> {
const res = await fetch(`/api/admin/items/${id}/mark-available`, { method: 'POST' });
return res.json();
}
export async function createPaypalOrder(itemId: number): Promise<string> {
const res = await fetch(`/api/checkout/paypal/${itemId}/create`, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Could not start checkout');
return data.orderID;
}
export async function capturePaypalOrder(itemId: number, orderID: string): Promise<void> {
const res = await fetch(`/api/checkout/paypal/${itemId}/capture`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orderID })
});
if (!res.ok) throw new Error('Payment captured but confirmation failed — contact the seller.');
}
export async function demoPurchase(itemId: number): Promise<void> {
const res = await fetch(`/api/checkout/demo/${itemId}/purchase`, { method: 'POST' });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Demo purchase failed');
}
}
+126
View File
@@ -0,0 +1,126 @@
import { useEffect, useRef, useState } from 'react';
import { Card, Badge, Typography, Carousel, Button, message } from 'antd';
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
import type { CarouselRef } from 'antd/es/carousel';
import { Item, SiteConfig, createPaypalOrder, capturePaypalOrder, demoPurchase } from '../api';
import { loadPaypalSdk } from '../paypal';
import MarkdownView from './MarkdownView';
const { Text, Title } = Typography;
declare global {
interface Window { paypal?: any; }
}
interface Props {
item: Item;
config: SiteConfig;
onPurchased: () => void;
}
export default function ItemCard({ item, config, onPurchased }: Props) {
const slotRef = useRef<HTMLDivElement>(null);
const carouselRef = useRef<CarouselRef>(null);
const [paypalReady, setPaypalReady] = useState(false);
useEffect(() => {
if (item.status !== 'available' || !config.paypalClientId) return;
loadPaypalSdk(config.paypalClientId, config.currency)
.then(() => setPaypalReady(true))
.catch(err => console.error(err));
}, [item.status, config.paypalClientId, config.currency]);
useEffect(() => {
if (!paypalReady || !window.paypal || !slotRef.current) return;
slotRef.current.innerHTML = '';
window.paypal.Buttons({
createOrder: () => createPaypalOrder(item.id),
onApprove: async (data: { orderID: string }) => {
try {
await capturePaypalOrder(item.id, data.orderID);
message.success('Purchase complete — thank you!');
onPurchased();
} catch (err) {
message.error((err as Error).message);
}
},
onError: (err: unknown) => {
console.error(err);
message.error('Checkout error, please try again.');
}
}).render(slotRef.current);
}, [paypalReady, item.id]);
async function handleDemoBuy() {
try {
await demoPurchase(item.id);
message.success('Demo purchase complete — item marked sold.');
onPurchased();
} catch (err) {
message.error((err as Error).message);
}
}
const hasMultiple = item.images.length > 1;
const cover = item.images.length ? (
<div className="carousel-wrap">
<Carousel ref={carouselRef} dots={hasMultiple}>
{item.images.map(img => (
<div key={img.id}>
<img src={img.image_path} alt={item.name} className="card-cover-img" />
</div>
))}
</Carousel>
{hasMultiple && (
<>
<Button
className="carousel-arrow carousel-arrow-left"
shape="circle"
size="small"
icon={<LeftOutlined />}
onClick={(e) => { e.stopPropagation(); carouselRef.current?.prev(); }}
/>
<Button
className="carousel-arrow carousel-arrow-right"
shape="circle"
size="small"
icon={<RightOutlined />}
onClick={(e) => { e.stopPropagation(); carouselRef.current?.next(); }}
/>
<div className="carousel-count">{item.images.length} photos</div>
</>
)}
</div>
) : (
<div className="card-cover-placeholder" />
);
const card = (
<Card hoverable cover={cover} className="item-card">
<Title level={5} style={{ marginBottom: 4 }}>{item.name}</Title>
<MarkdownView content={item.description} />
<div className="price">${(item.price_cents / 100).toFixed(2)}</div>
{item.status === 'available' && (
<>
{config.paypalClientId && <div className="paypal-slot" ref={slotRef} />}
{config.demoMode && (
<Button
block
type={config.paypalClientId ? 'default' : 'primary'}
onClick={handleDemoBuy}
style={{ marginTop: 8 }}
>
{config.paypalClientId ? 'Buy Now (Demo)' : 'Buy Now'}
</Button>
)}
</>
)}
</Card>
);
if (item.status === 'sold') {
return <Badge.Ribbon text="SOLD" color="black">{card}</Badge.Ribbon>;
}
return card;
}
+15
View File
@@ -0,0 +1,15 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
interface Props {
content: string | null;
}
export default function MarkdownView({ content }: Props) {
if (!content) return null;
return (
<div className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
import { useEffect, useState } from 'react';
import { Card, Typography, Switch, Button, Table, Modal, message, Space, Divider } from 'antd';
import { useNavigate } from 'react-router-dom';
import { fetchMyOrders, OrderHistoryItem, updateConsent, exportMyData, deleteMyAccount, logoutCustomer } from './customerApi';
import { useCustomerAuth } from './CustomerAuthContext';
const { Title, Text } = Typography;
export default function Account() {
const { customer, loading, refresh } = useCustomerAuth();
const [orders, setOrders] = useState<OrderHistoryItem[]>([]);
const navigate = useNavigate();
useEffect(() => {
if (customer) fetchMyOrders().then(setOrders);
}, [customer]);
useEffect(() => {
if (!loading && !customer) navigate('/login');
}, [loading, customer, navigate]);
if (!customer) return null;
async function handleConsentToggle(checked: boolean) {
await updateConsent(checked);
message.success(checked ? 'Subscribed to emails' : 'Unsubscribed from emails');
refresh();
}
async function handleLogout() {
await logoutCustomer();
navigate('/');
}
function handleDelete() {
Modal.confirm({
title: 'Delete your account?',
content: 'This permanently removes your account and personal data. Your past orders are kept for accounting purposes but disconnected from your identity. This cannot be undone.',
okText: 'Delete my account',
okButtonProps: { danger: true },
onOk: async () => {
await deleteMyAccount();
message.success('Account deleted');
navigate('/');
}
});
}
return (
<div style={{ maxWidth: 700, margin: '48px auto', padding: '0 16px' }}>
<Card>
<Title level={3}>My Account</Title>
<Text>{customer.email}</Text>
{!customer.email_verified && (
<div style={{ marginTop: 8 }}>
<Text type="warning">Email not verified check your inbox for a verification link.</Text>
</div>
)}
<Divider />
<Space align="center">
<Switch checked={customer.marketing_consent} onChange={handleConsentToggle} />
<Text>Receive emails about new items</Text>
</Space>
<Divider />
<Title level={5}>Order History</Title>
<Table
rowKey="id"
size="small"
dataSource={orders}
pagination={false}
columns={[
{ title: 'Item', dataIndex: 'item_name' },
{ title: 'Amount', dataIndex: 'amount_cents', render: (v: number) => `$${(v / 100).toFixed(2)}` },
{ title: 'Processor', dataIndex: 'processor' },
{ title: 'Date', dataIndex: 'created_at', render: (v: string) => new Date(v).toLocaleDateString() }
]}
/>
<Divider />
<Space wrap>
<Button onClick={exportMyData}>Download my data</Button>
<Button onClick={handleLogout}>Log out</Button>
<Button danger onClick={handleDelete}>Delete my account</Button>
</Space>
</Card>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
import { Customer, fetchMe } from './customerApi';
interface CustomerAuthValue {
customer: Customer | null;
loading: boolean;
refresh: () => void;
}
const CustomerAuthContext = createContext<CustomerAuthValue>({ customer: null, loading: true, refresh: () => {} });
export function useCustomerAuth() {
return useContext(CustomerAuthContext);
}
export function CustomerAuthProvider({ children }: { children: React.ReactNode }) {
const [customer, setCustomer] = useState<Customer | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(() => {
setLoading(true);
fetchMe().then(c => { setCustomer(c); setLoading(false); });
}, []);
useEffect(() => { refresh(); }, [refresh]);
return (
<CustomerAuthContext.Provider value={{ customer, loading, refresh }}>
{children}
</CustomerAuthContext.Provider>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { useState } from 'react';
import { Form, Input, Button, Typography, Card, Alert } from 'antd';
import { useNavigate, Link } from 'react-router-dom';
import { loginCustomer } from './customerApi';
import { useCustomerAuth } from './CustomerAuthContext';
const { Title, Text } = Typography;
export default function Login() {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const { refresh } = useCustomerAuth();
async function onFinish(values: any) {
setLoading(true);
setError(null);
try {
await loginCustomer(values.email, values.password);
refresh();
navigate('/account');
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
}
return (
<div style={{ maxWidth: 420, margin: '48px auto', padding: '0 16px' }}>
<Card>
<Title level={3}>Log in</Title>
{error && <Alert type="error" message={error} style={{ marginBottom: 16 }} />}
<Form layout="vertical" onFinish={onFinish}>
<Form.Item name="email" label="Email" rules={[{ required: true, type: 'email' }]}>
<Input />
</Form.Item>
<Form.Item name="password" label="Password" rules={[{ required: true }]}>
<Input.Password />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>Log in</Button>
</Form.Item>
</Form>
<Text type="secondary">
No account yet? <Link to="/register">Create one</Link>
</Text>
</Card>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { Typography, Card } from 'antd';
const { Title, Paragraph } = Typography;
export default function PrivacyPolicy() {
return (
<div style={{ maxWidth: 720, margin: '48px auto', padding: '0 16px' }}>
<Card>
<Title level={2}>Privacy Policy</Title>
<Paragraph type="secondary">
This is a general-purpose starter policy. Have it reviewed by a lawyer before relying on it
it is not legal advice and may not reflect your specific obligations.
</Paragraph>
<Title level={4}>What we collect</Title>
<Paragraph>
If you create an account, we collect your email address, name (optional), and a securely
hashed password. If you make a purchase, we record the item, amount, and payment processor
transaction reference. We do not store your card or PayPal login details payment is handled
entirely by our payment processor.
</Paragraph>
<Title level={4}>Marketing emails</Title>
<Paragraph>
We only send you marketing emails if you explicitly opt in during signup or in your account
settings. You can withdraw consent at any time from your account page, or via the unsubscribe
link included in every marketing email no login required.
</Paragraph>
<Title level={4}>Your rights</Title>
<Paragraph>
You may request a copy of your data ("Download my data" in your account page), or delete your
account entirely at any time. Deleting your account removes your personal information; past
order records are retained in anonymized form for accounting purposes.
</Paragraph>
<Title level={4}>Data retention</Title>
<Paragraph>
Account data is retained until you delete your account. Order records are retained as required
for financial recordkeeping, disconnected from your identity upon account deletion.
</Paragraph>
<Title level={4}>Contact</Title>
<Paragraph>
For privacy questions or data requests, contact us at the email address listed on this site.
</Paragraph>
</Card>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { useState } from 'react';
import { Form, Input, Button, Checkbox, Typography, Card, Alert } from 'antd';
import { useNavigate, Link } from 'react-router-dom';
import { registerCustomer } from './customerApi';
import { useCustomerAuth } from './CustomerAuthContext';
const { Title, Text } = Typography;
export default function Register() {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const { refresh } = useCustomerAuth();
async function onFinish(values: any) {
setLoading(true);
setError(null);
try {
await registerCustomer(values.email, values.password, values.name, !!values.marketingConsent);
refresh();
navigate('/account');
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
}
return (
<div style={{ maxWidth: 420, margin: '48px auto', padding: '0 16px' }}>
<Card>
<Title level={3}>Create an account</Title>
{error && <Alert type="error" message={error} style={{ marginBottom: 16 }} />}
<Form layout="vertical" onFinish={onFinish}>
<Form.Item name="name" label="Name">
<Input />
</Form.Item>
<Form.Item name="email" label="Email" rules={[{ required: true, type: 'email' }]}>
<Input />
</Form.Item>
<Form.Item name="password" label="Password" rules={[{ required: true, min: 8, message: 'At least 8 characters' }]}>
<Input.Password />
</Form.Item>
<Form.Item name="marketingConsent" valuePropName="checked" initialValue={false}>
<Checkbox>
Send me occasional emails about new one-of-a-kind items. I can unsubscribe at any time.
</Checkbox>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>Create account</Button>
</Form.Item>
</Form>
<Text type="secondary">
Already have an account? <Link to="/login">Log in</Link>
</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
By creating an account you agree to our <Link to="/privacy">Privacy Policy</Link>.
</Text>
</Card>
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
export interface Customer {
id: number;
email: string;
name: string | null;
email_verified: boolean;
marketing_consent: boolean;
created_at: string;
}
export interface OrderHistoryItem {
id: number;
processor: string;
amount_cents: number;
status: string;
created_at: string;
item_name: string;
}
async function handle<T>(res: Response): Promise<T> {
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Request failed');
}
return res.json();
}
export function registerCustomer(email: string, password: string, name: string, marketingConsent: boolean): Promise<Customer> {
return fetch('/api/customers/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, name, marketingConsent })
}).then(res => handle<Customer>(res));
}
export function loginCustomer(email: string, password: string): Promise<Customer> {
return fetch('/api/customers/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
}).then(res => handle<Customer>(res));
}
export function logoutCustomer(): Promise<void> {
return fetch('/api/customers/logout', { method: 'POST' }).then(() => undefined);
}
export function fetchMe(): Promise<Customer | null> {
return fetch('/api/customers/me').then(res => (res.ok ? res.json() : null));
}
export function updateConsent(marketingConsent: boolean): Promise<void> {
return fetch('/api/customers/me/consent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ marketingConsent })
}).then(() => undefined);
}
export function fetchMyOrders(): Promise<OrderHistoryItem[]> {
return fetch('/api/customers/me/orders').then(res => handle<OrderHistoryItem[]>(res));
}
export function deleteMyAccount(): Promise<void> {
return fetch('/api/customers/me', { method: 'DELETE' }).then(() => undefined);
}
export function exportMyData(): void {
window.location.href = '/api/customers/me/export';
}
+47
View File
@@ -0,0 +1,47 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { ConfigProvider, theme as antdTheme } from 'antd';
import 'antd/dist/reset.css';
import App from './App';
import Admin from './admin/Admin';
import Login from './customer/Login';
import Register from './customer/Register';
import Account from './customer/Account';
import PrivacyPolicy from './customer/PrivacyPolicy';
import { CustomerAuthProvider } from './customer/CustomerAuthContext';
import { ThemeModeProvider, useThemeMode } from './theme/ThemeContext';
import './styles.css';
function Root() {
const { mode } = useThemeMode();
return (
<ConfigProvider
theme={{
algorithm: mode === 'dark' ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
token: { colorPrimary: '#1a1a1a' }
}}
>
<BrowserRouter>
<Routes>
<Route path="/" element={<App />} />
<Route path="/admin" element={<Admin />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/account" element={<Account />} />
<Route path="/privacy" element={<PrivacyPolicy />} />
</Routes>
</BrowserRouter>
</ConfigProvider>
);
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeModeProvider>
<CustomerAuthProvider>
<Root />
</CustomerAuthProvider>
</ThemeModeProvider>
</React.StrictMode>
);
+14
View File
@@ -0,0 +1,14 @@
let loadPromise: Promise<void> | null = null;
export function loadPaypalSdk(clientId: string, currency: string): Promise<void> {
if ((window as any).paypal) return Promise.resolve();
if (loadPromise) return loadPromise;
loadPromise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = `https://www.paypal.com/sdk/js?client-id=${encodeURIComponent(clientId)}&currency=${currency}`;
script.onload = () => resolve();
script.onerror = () => reject(new Error('failed to load paypal sdk'));
document.head.appendChild(script);
});
return loadPromise;
}
+33
View File
@@ -0,0 +1,33 @@
body { margin: 0; }
.item-card .card-cover-img { width: 100%; height: 220px; object-fit: cover; display: block; }
.item-card .card-cover-placeholder { width: 100%; height: 220px; background: #eee; }
.price { font-weight: 600; margin: 8px 0; font-size: 16px; }
.paypal-slot { margin-top: 8px; }
.carousel-wrap { position: relative; }
.carousel-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 2;
opacity: 0.85;
}
.carousel-arrow-left { left: 8px; }
.carousel-arrow-right { right: 8px; }
.carousel-count {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0,0,0,0.6);
color: #fff;
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
z-index: 2;
}
.markdown-body { font-size: 14px; }
.markdown-body p { margin: 4px 0; }
.markdown-body ul, .markdown-body ol { margin: 4px 0; padding-left: 20px; }
.markdown-body h1, .markdown-body h2, .markdown-body h3 { font-size: 15px; margin: 8px 0 4px; }
+40
View File
@@ -0,0 +1,40 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
type ThemeMode = 'light' | 'dark';
interface ThemeContextValue {
mode: ThemeMode;
toggle: () => void;
}
const ThemeContext = createContext<ThemeContextValue>({ mode: 'light', toggle: () => {} });
export function useThemeMode() {
return useContext(ThemeContext);
}
const STORAGE_KEY = 'redefined-designs-theme';
function getInitialMode(): ThemeMode {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'light' || stored === 'dark') return stored;
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
export function ThemeModeProvider({ children }: { children: React.ReactNode }) {
const [mode, setMode] = useState<ThemeMode>(getInitialMode);
useEffect(() => {
localStorage.setItem(STORAGE_KEY, mode);
document.body.setAttribute('data-theme', mode);
}, [mode]);
const value = useMemo(
() => ({ mode, toggle: () => setMode(m => (m === 'light' ? 'dark' : 'light')) }),
[mode]
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}