Initial commit: redefined-designs storefront
This commit is contained in:
Executable
+12
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Redefined Designs</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "redefined-designs-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc && vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"antd": "^5.20.6",
|
||||
"@ant-design/icons": "^5.4.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"@uiw/react-md-editor": "^4.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
Executable
+68
@@ -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>
|
||||
);
|
||||
}
|
||||
Executable
+221
@@ -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>
|
||||
);
|
||||
}
|
||||
Executable
+150
@@ -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>
|
||||
);
|
||||
}
|
||||
Executable
+44
@@ -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();
|
||||
}
|
||||
Executable
+83
@@ -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');
|
||||
}
|
||||
}
|
||||
Executable
+126
@@ -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;
|
||||
}
|
||||
Executable
+15
@@ -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>
|
||||
);
|
||||
}
|
||||
Executable
+90
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
Executable
+51
@@ -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>
|
||||
);
|
||||
}
|
||||
Executable
+50
@@ -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>
|
||||
);
|
||||
}
|
||||
Executable
+63
@@ -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>
|
||||
);
|
||||
}
|
||||
Executable
+69
@@ -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';
|
||||
}
|
||||
Executable
+47
@@ -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>
|
||||
);
|
||||
Executable
+14
@@ -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)}¤cy=${currency}`;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error('failed to load paypal sdk'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
return loadPromise;
|
||||
}
|
||||
Executable
+33
@@ -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; }
|
||||
Executable
+40
@@ -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>;
|
||||
}
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: { outDir: 'dist' }
|
||||
});
|
||||
Reference in New Issue
Block a user