Compare commits

..

1 Commits

Author SHA1 Message Date
AppCakes c8ec54c22d build: da8aa2fb-9871-4e3e-a583-70eda77c4d68 2026-06-12 11:55:41 +00:00
84 changed files with 4451 additions and 8632 deletions
Vendored
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -4,7 +4,7 @@ android {
namespace = "io.ionic.starter"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "io.ionic.starter"
applicationId "io.shopkeep.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
+6
View File
@@ -21,6 +21,12 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="io.shopkeep.app" />
</intent-filter>
</activity>
+2 -2
View File
@@ -1,7 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">bringg</string>
<string name="title_activity_main">bringg</string>
<string name="app_name">ShopKeep</string>
<string name="title_activity_main">ShopKeep</string>
<string name="package_name">io.ionic.starter</string>
<string name="custom_url_scheme">io.ionic.starter</string>
</resources>
-1
View File
@@ -15,5 +15,4 @@ ext {
cordovaAndroidVersion = '14.0.1'
rgcfaIncludeGoogle = true
androidxCredentialsVersion = '1.3.0'
javaVersion = JavaVersion.VERSION_17
}
+2 -2
View File
@@ -1,8 +1,8 @@
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.bringg.app',
appName: 'bringg',
appId: 'io.shopkeep.app',
appName: 'ShopKeep',
webDir: 'dist',
plugins: {
FirebaseAuthentication: {
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+6 -2
View File
@@ -26,6 +26,7 @@
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
1A4EB3C21FED79650016851F /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
/* End PBXFileReference section */
@@ -68,6 +69,7 @@
504EC30E1FED79650016851F /* Assets.xcassets */,
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
504EC3131FED79650016851F /* Info.plist */,
1A4EB3C21FED79650016851F /* App.entitlements */,
2FAD9762203C412B000D30F8 /* config.xml */,
50B271D01FEDC1A000F3C39B /* public */,
);
@@ -295,6 +297,7 @@
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = App/Info.plist;
@@ -305,7 +308,7 @@
);
MARKETING_VERSION = 1.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.bringg.app;
PRODUCT_BUNDLE_IDENTIFIER = io.shopkeep.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0;
@@ -317,6 +320,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = App/Info.plist;
@@ -326,7 +330,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.bringg.app;
PRODUCT_BUNDLE_IDENTIFIER = io.shopkeep.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0;
+3 -3
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>bringg</string>
<string>ShopKeep</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -51,10 +51,10 @@
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.bringg.app</string>
<string>io.shopkeep.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.bringg.app</string>
<string>io.shopkeep.app</string>
</array>
</dict>
</array>
+3 -2
View File
@@ -20,9 +20,10 @@
"@capacitor/keyboard": "8.0.3",
"@capacitor/push-notifications": "^8.1.1",
"@capacitor/status-bar": "8.0.2",
"@hookform/resolvers": "^3.9.0",
"@ionic/pwa-elements": "^3.0.0",
"@ionic/react": "^8.5.0",
"@ionic/react-router": "^8.5.0",
"@ionic/react": "8.8.7",
"@ionic/react-router": "8.8.7",
"@supabase/supabase-js": "^2.0.0",
"firebase": "^11.0.0",
"ionicons": "^7.4.0",
BIN
View File
Binary file not shown.
+134 -52
View File
@@ -1,12 +1,50 @@
import React from 'react';
import React, { useEffect } from 'react';
import { Redirect, Route } from 'react-router-dom';
import { IonApp, IonRouterOutlet } from '@ionic/react';
import {
IonApp,
IonRouterOutlet,
IonTabs,
IonTabBar,
IonTabButton,
IonIcon,
IonLabel,
setupIonicReact,
} from '@ionic/react';
import { IonReactRouter } from '@ionic/react-router';
import {
homeOutline,
peopleOutline,
receiptOutline,
home,
people,
receipt,
} from 'ionicons/icons';
import Home from './pages/Home';
import CustomersPage from './pages/CustomersPage';
import CustomerDetailPage from './pages/CustomerDetailPage';
import HistoryPage from './pages/HistoryPage';
import SaleFormPage from './pages/SaleFormPage';
import CreditFormPage from './pages/CreditFormPage';
import RepaymentFormPage from './pages/RepaymentFormPage';
import AuthPage from './pages/AuthPage';
import VerifyEmailPage from './pages/VerifyEmailPage';
import ForgotPasswordPage from './pages/ForgotPasswordPage';
import NewPasswordPage from './pages/NewPasswordPage';
import { AuthProvider } from './contexts/AuthContext';
import ProtectedRoute from './components/ProtectedRoute';
import { setStatusBarStyle, Style } from './utils/statusBar';
/* Core CSS required for Ionic components to work properly */
import '@ionic/react/css/core.css';
/* Basic CSS for apps built with Ionic */
import '@ionic/react/css/normalize.css';
import '@ionic/react/css/structure.css';
import '@ionic/react/css/typography.css';
/* Optional CSS utils that can be commented out */
import '@ionic/react/css/padding.css';
import '@ionic/react/css/float-elements.css';
import '@ionic/react/css/text-alignment.css';
@@ -14,60 +52,104 @@ import '@ionic/react/css/text-transformation.css';
import '@ionic/react/css/flex-utils.css';
import '@ionic/react/css/display.css';
/**
* Ionic Dark Mode
* -----------------------------------------------------
* For more info, please see:
* https://ionicframework.com/docs/theming/dark-mode
*/
/* import '@ionic/react/css/palettes/dark.always.css'; */
/* import '@ionic/react/css/palettes/dark.class.css'; */
import '@ionic/react/css/palettes/dark.system.css';
/* Theme variables */
import './theme/variables.css';
import Home from './pages/Home';
import ProductDetail from './pages/ProductDetail';
import Cart from './pages/Cart';
import OrderConfirmation from './pages/OrderConfirmation';
import MyOrders from './pages/MyOrders';
import OrderDetail from './pages/OrderDetail';
import Auth from './pages/Auth';
import Profile from './pages/Profile';
import Addresses from './pages/Addresses';
import Wallet from './pages/Wallet';
import PaymentMethods from './pages/PaymentMethods';
import AddPaymentMethod from './pages/AddPaymentMethod';
import AdminOrders from './pages/AdminOrders';
import AdminOrderDetail from './pages/AdminOrderDetail';
import { AuthProvider } from './context/AuthContext';
setupIonicReact();
const App: React.FC = () => (
<IonApp>
<IonReactRouter>
const App: React.FC = () => {
useEffect(() => {
// Set status bar to light icons on our dark-ish backgrounds
setStatusBarStyle(Style.Dark);
}, []);
return (
<IonApp>
<AuthProvider>
<IonRouterOutlet>
<Route path="/home" component={Home} exact />
<Route path="/auth" component={Auth} exact />
<Route path="/product/:id" component={ProductDetail} exact />
<Route path="/cart" component={Cart} exact />
<Route path="/orders" component={MyOrders} exact />
<Route path="/orders/:id" component={OrderDetail} exact />
<Route path="/profile" component={Profile} exact />
<Route path="/addresses" component={Addresses} exact />
<Route path="/wallet" component={Wallet} exact />
<Route
path="/wallet/payment-methods"
component={PaymentMethods}
exact
/>
<Route
path="/wallet/payment-methods/add"
component={AddPaymentMethod}
exact
/>
<Route path="/admin/orders" component={AdminOrders} exact />
<Route path="/admin/orders/:id" component={AdminOrderDetail} exact />
<Route
path="/order-confirmation"
component={OrderConfirmation}
exact
/>
<Redirect exact from="/" to="/home" />
</IonRouterOutlet>
<IonReactRouter>
<IonRouterOutlet>
{/* Auth Routes */}
<Route path="/auth" component={AuthPage} exact />
<Route path="/verify-email" component={VerifyEmailPage} exact />
<Route
path="/forgot-password"
component={ForgotPasswordPage}
exact
/>
<Route path="/new-password" component={NewPasswordPage} exact />
{/* Protected Form & Detail Routes (Outer Outlet for push/pop animation) */}
<ProtectedRoute path="/sale/new" component={SaleFormPage} exact />
<ProtectedRoute
path="/credit/new"
component={CreditFormPage}
exact
/>
<ProtectedRoute
path="/repayment/new"
component={RepaymentFormPage}
exact
/>
<ProtectedRoute
path="/customers/:id([0-9a-fA-F-]{36})"
component={CustomerDetailPage}
exact
/>
{/* Tab Shell */}
<Route
path={['/home', '/customers', '/history']}
render={() => (
<IonTabs>
<IonRouterOutlet>
<ProtectedRoute path="/home" component={Home} exact />
<ProtectedRoute
path="/customers"
component={CustomersPage}
exact
/>
<ProtectedRoute
path="/history"
component={HistoryPage}
exact
/>
</IonRouterOutlet>
<IonTabBar slot="bottom">
<IonTabButton tab="home" href="/home">
<IonIcon icon={homeOutline} />
<IonLabel>Home</IonLabel>
</IonTabButton>
<IonTabButton tab="customers" href="/customers">
<IonIcon icon={peopleOutline} />
<IonLabel>Customers</IonLabel>
</IonTabButton>
<IonTabButton tab="history" href="/history">
<IonIcon icon={receiptOutline} />
<IonLabel>History</IonLabel>
</IonTabButton>
</IonTabBar>
</IonTabs>
)}
/>
{/* Root Redirect */}
<Route exact path="/" render={() => <Redirect to="/home" />} />
</IonRouterOutlet>
</IonReactRouter>
</AuthProvider>
</IonReactRouter>
</IonApp>
);
</IonApp>
);
};
export default App;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 839 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 501 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 553 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 676 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 673 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 545 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 550 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 461 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 689 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 690 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 599 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 464 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 629 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 515 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 655 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 697 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 466 KiB

+130
View File
@@ -0,0 +1,130 @@
import React from 'react';
import { IonIcon, IonItem, IonLabel, IonNote } from '@ionic/react';
import {
receiptOutline,
personAddOutline,
cashOutline,
chevronForwardOutline,
} from 'ionicons/icons';
import { formatMoney } from '../utils/money';
import { useAuth } from '../contexts/AuthContext';
import { formatDisplayDate } from '../utils/dates';
export type ActivityType = 'sale' | 'credit' | 'repayment';
export interface ActivityItem {
id: string;
type: ActivityType;
amount: number;
date: string;
customerName?: string;
note?: string;
customerId?: string;
}
interface ActivityRowProps {
item: ActivityItem;
onClick?: () => void;
showDate?: boolean;
}
const ActivityRow: React.FC<ActivityRowProps> = ({
item,
onClick,
showDate = false,
}) => {
const { profile } = useAuth();
const currency = profile?.currency || 'USD';
const getIcon = () => {
switch (item.type) {
case 'sale':
return receiptOutline;
case 'credit':
return personAddOutline;
case 'repayment':
return cashOutline;
}
};
const getTitle = () => {
switch (item.type) {
case 'sale':
return 'Cash Sale';
case 'credit':
return `Credit: ${item.customerName || 'Customer'}`;
case 'repayment':
return `Repayment: ${item.customerName || 'Customer'}`;
}
};
const getAmountColor = () => {
switch (item.type) {
case 'sale':
return 'var(--color-text-primary)';
case 'credit':
return 'var(--ion-color-primary)';
case 'repayment':
return 'var(--ion-color-success)';
}
};
return (
<IonItem
lines="none"
onClick={onClick}
detail={false}
style={{
'--background': 'transparent',
'--padding-start': '0',
'--padding-end': '0',
'--inner-padding-end': '0',
cursor: onClick ? 'pointer' : 'default',
}}
>
<div
className="icon-container"
slot="start"
style={{ marginRight: '12px' }}
>
<IonIcon icon={getIcon()} />
</div>
<IonLabel>
<h3 className="semibold" style={{ margin: '0', fontSize: '15px' }}>
{getTitle()}
</h3>
<p className="secondary-text" style={{ margin: '2px 0 0 0' }}>
{showDate ? formatDisplayDate(item.date) : item.note || 'No notes'}
</p>
</IonLabel>
<div
slot="end"
style={{ textAlign: 'right', display: 'flex', alignItems: 'center' }}
>
<IonNote
className="tabular"
style={{
fontSize: '15px',
color: getAmountColor(),
fontWeight: '400',
}}
>
{item.type === 'credit' ? '-' : '+'}
{formatMoney(item.amount, currency)}
</IonNote>
{onClick && (
<IonIcon
icon={chevronForwardOutline}
style={{
fontSize: '14px',
marginLeft: '8px',
color: 'var(--color-text-tertiary)',
}}
/>
)}
</div>
</IonItem>
);
};
export default ActivityRow;
-36
View File
@@ -1,36 +0,0 @@
import React from 'react';
import { Redirect, Route, RouteProps } from 'react-router-dom';
import { IonLoading } from '@ionic/react';
import { useAuth } from '../context/AuthContext';
type Props = RouteProps;
const AdminRoute: React.FC<Props> = ({ component: Component, ...rest }) => {
const { user, profile, isLoading } = useAuth();
if (!Component) return null;
return (
<Route
{...rest}
render={(props) => {
if (isLoading) {
return <IonLoading isOpen message="Loading..." />;
}
if (!user) {
return <Redirect to="/auth" />;
}
if (!profile?.is_admin) {
return <Redirect to="/home" />;
}
const RouteComponent = Component as React.ComponentType<any>;
return <RouteComponent {...props} />;
}}
/>
);
};
export default AdminRoute;
+115
View File
@@ -0,0 +1,115 @@
import React from 'react';
import { IonIcon, IonItem, IonLabel, IonNote } from '@ionic/react';
import { chevronForwardOutline } from 'ionicons/icons';
import { formatMoney } from '../utils/money';
import { useAuth } from '../contexts/AuthContext';
interface CustomerRowProps {
id: string;
name: string;
balance: number;
phone?: string;
onClick: () => void;
}
const CustomerRow: React.FC<CustomerRowProps> = ({
id,
name,
balance,
phone,
onClick,
}) => {
const { profile } = useAuth();
const currency = profile?.currency || 'USD';
const getInitials = (name: string) => {
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2);
};
return (
<IonItem
lines="none"
onClick={onClick}
detail={false}
style={{
'--background': 'transparent',
'--padding-start': '0',
'--padding-end': '0',
'--inner-padding-end': '0',
cursor: 'pointer',
marginBottom: '8px',
}}
>
<div
className="icon-container bold"
slot="start"
style={{
marginRight: '16px',
width: '44px',
height: '44px',
fontSize: '14px',
color: 'var(--ion-color-primary)',
}}
>
{getInitials(name)}
</div>
<IonLabel>
<h3 className="semibold" style={{ margin: '0', fontSize: '16px' }}>
{name}
</h3>
{phone && (
<p className="secondary-text" style={{ margin: '2px 0 0 0' }}>
{phone}
</p>
)}
</IonLabel>
<div
slot="end"
style={{ textAlign: 'right', display: 'flex', alignItems: 'center' }}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
}}
>
<IonNote
className="tabular"
style={{
fontSize: '16px',
color:
balance > 0
? 'var(--ion-color-danger)'
: 'var(--ion-color-success)',
fontWeight: '400',
}}
>
{formatMoney(balance, currency)}
</IonNote>
<span
className="tertiary-text"
style={{ fontSize: '10px', marginTop: '2px' }}
>
{balance > 0 ? 'OUTSTANDING' : 'CLEARED'}
</span>
</div>
<IonIcon
icon={chevronForwardOutline}
style={{
fontSize: '16px',
marginLeft: '12px',
color: 'var(--color-text-tertiary)',
}}
/>
</div>
</IonItem>
);
};
export default CustomerRow;
+79
View File
@@ -0,0 +1,79 @@
import React from 'react';
import { IonIcon, IonButton } from '@ionic/react';
interface EmptyStateProps {
icon: string;
title: string;
description?: string;
actionText?: string;
onAction?: () => void;
}
const EmptyState: React.FC<EmptyStateProps> = ({
icon,
title,
description,
actionText,
onAction,
}) => {
return (
<div
className="widget-card"
style={{
padding: '40px 20px',
alignItems: 'center',
textAlign: 'center',
justifyContent: 'center',
}}
>
<div
className="icon-container"
style={{
width: '64px',
height: '64px',
borderRadius: '20px',
marginBottom: '20px',
background: 'var(--color-surface-secondary)',
color: 'var(--color-text-tertiary)',
}}
>
<IonIcon icon={icon} style={{ fontSize: '32px' }} />
</div>
<h3
style={{
margin: '0 0 8px 0',
fontSize: '18px',
fontWeight: '700',
color: 'var(--color-text-primary)',
}}
>
{title}
</h3>
{description && (
<p
className="secondary-text"
style={{ margin: '0 0 24px 0', maxWidth: '240px' }}
>
{description}
</p>
)}
{actionText && onAction && (
<IonButton
onClick={onAction}
shape="round"
fill="solid"
style={{
height: '44px',
'--border-radius': '999px',
fontSize: '14px',
fontWeight: '700',
}}
>
{actionText}
</IonButton>
)}
</div>
);
};
export default EmptyState;
+99
View File
@@ -0,0 +1,99 @@
import React from 'react';
import { IonIcon } from '@ionic/react';
import { formatMoney } from '../utils/money';
import { useAuth } from '../contexts/AuthContext';
interface MetricCardProps {
label: string;
value: number;
icon?: string;
subtitle?: string;
fullWidth?: boolean;
loading?: boolean;
}
const MetricCard: React.FC<MetricCardProps> = ({
label,
value,
icon,
subtitle,
fullWidth = false,
loading = false,
}) => {
const { profile } = useAuth();
const currency = profile?.currency || 'USD';
if (loading) {
return (
<div
className="widget-card"
style={{
width: fullWidth ? '100%' : 'auto',
minHeight: fullWidth ? '160px' : '100px',
}}
>
<div
className="skeleton-text"
style={{
width: '40%',
height: '12px',
background: 'var(--color-surface-secondary)',
borderRadius: '4px',
marginBottom: '12px',
}}
/>
<div
className="skeleton-text"
style={{
width: '70%',
height: '32px',
background: 'var(--color-surface-secondary)',
borderRadius: '8px',
}}
/>
</div>
);
}
return (
<div
className="widget-card"
style={{
width: fullWidth ? '100%' : 'auto',
minHeight: fullWidth ? '160px' : '100px',
justifyContent: 'center',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
}}
>
<div>
<span className="tertiary-text">{label}</span>
<div
className="tabular"
style={{
fontSize: fullWidth ? '34px' : '24px',
fontWeight: '400',
color: 'var(--color-text-primary)',
marginTop: '4px',
}}
>
{formatMoney(value, currency)}
</div>
{subtitle && <div className="secondary-text">{subtitle}</div>}
</div>
{icon && (
<div className="icon-container">
<IonIcon icon={icon} />
</div>
)}
</div>
</div>
);
};
export default MetricCard;
+88
View File
@@ -0,0 +1,88 @@
import React from 'react';
import { IonInput, IonLabel } from '@ionic/react';
import { sanitizeMoneyInput, getCurrencySymbol } from '../utils/money';
import { useAuth } from '../contexts/AuthContext';
interface MoneyInputProps {
value: string;
onValueChange: (value: string) => void;
label?: string;
placeholder?: string;
error?: string;
}
const MoneyInput: React.FC<MoneyInputProps> = ({
value,
onValueChange,
label,
placeholder = '0.00',
error,
}) => {
const { profile } = useAuth();
const symbol = getCurrencySymbol(profile?.currency || 'USD');
const handleChange = (e: CustomEvent) => {
const rawValue = e.detail.value || '';
const sanitized = sanitizeMoneyInput(rawValue);
onValueChange(sanitized);
};
return (
<div className="money-input-container">
{label && (
<IonLabel
position="stacked"
className="semibold"
style={{ marginBottom: '8px', display: 'block', fontSize: '13px' }}
>
{label}
</IonLabel>
)}
<div
className="input-wrapper"
style={{
border: error ? '1px solid var(--ion-color-danger)' : 'none',
}}
>
<span
className="bold"
style={{
marginRight: '8px',
color: 'var(--color-text-primary)',
fontSize: '18px',
}}
>
{symbol}
</span>
<IonInput
type="text"
inputMode="decimal"
value={value}
placeholder={placeholder}
onIonChange={handleChange}
style={{
fontSize: '20px',
fontWeight: '700',
'--padding-top': '0',
'--padding-bottom': '0',
}}
className="tabular"
/>
</div>
{error && (
<IonLabel
color="danger"
style={{
fontSize: '12px',
marginTop: '4px',
display: 'block',
}}
>
{error}
</IonLabel>
)}
</div>
);
};
export default MoneyInput;
-101
View File
@@ -1,101 +0,0 @@
import React from 'react';
import {
IonButton,
IonIcon,
IonLabel,
IonModal,
IonRadio,
IonRadioGroup,
IonItem,
IonList,
} from '@ionic/react';
import { funnelOutline } from 'ionicons/icons';
export type SortOption = 'popular' | 'price-asc' | 'price-desc' | 'name-asc';
type Props = {
categories: string[];
selectedCategory: string;
setSelectedCategory: (value: string) => void;
sortOption: SortOption;
setSortOption: (value: SortOption) => void;
isOpen: boolean;
onClose: () => void;
};
const ProductFilters: React.FC<Props> = ({
categories,
selectedCategory,
setSelectedCategory,
sortOption,
setSortOption,
isOpen,
onClose,
}) => (
<>
<div className="filter-row">
<div className="filter-chip-row">
<IonButton
fill={selectedCategory === 'All' ? 'solid' : 'outline'}
size="small"
onClick={() => setSelectedCategory('All')}
>
All
</IonButton>
{categories.map((category) => (
<IonButton
key={category}
fill={selectedCategory === category ? 'solid' : 'outline'}
size="small"
onClick={() => setSelectedCategory(category)}
>
{category}
</IonButton>
))}
</div>
<IonButton fill="clear" className="filter-button" onClick={onClose}>
<IonIcon icon={funnelOutline} slot="start" />
Filters
</IonButton>
</div>
<IonModal
isOpen={isOpen}
onDidDismiss={onClose}
breakpoints={[0, 0.45]}
initialBreakpoint={0.45}
>
<div className="filter-modal ion-padding">
<h2>Sort products</h2>
<IonRadioGroup
value={sortOption}
onIonChange={(e) => setSortOption(e.detail.value)}
>
<IonList lines="none">
<IonItem>
<IonLabel>Most popular</IonLabel>
<IonRadio value="popular" />
</IonItem>
<IonItem>
<IonLabel>Price: low to high</IonLabel>
<IonRadio value="price-asc" />
</IonItem>
<IonItem>
<IonLabel>Price: high to low</IonLabel>
<IonRadio value="price-desc" />
</IonItem>
<IonItem>
<IonLabel>Name: A to Z</IonLabel>
<IonRadio value="name-asc" />
</IonItem>
</IonList>
</IonRadioGroup>
<IonButton expand="block" onClick={onClose}>
Done
</IonButton>
</div>
</IonModal>
</>
);
export default ProductFilters;
+12 -17
View File
@@ -1,29 +1,24 @@
import React from 'react';
import { Redirect, Route, RouteProps } from 'react-router-dom';
import { IonLoading } from '@ionic/react';
import { useAuth } from '../context/AuthContext';
import { Route, Redirect, RouteProps } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
type Props = RouteProps;
interface ProtectedRouteProps extends RouteProps {
component: React.ComponentType<any>;
}
const ProtectedRoute: React.FC<Props> = ({ component: Component, ...rest }) => {
const { user, isLoading } = useAuth();
if (!Component) return null;
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
component: Component,
...rest
}) => {
const { user, loading } = useAuth();
return (
<Route
{...rest}
render={(props) => {
if (isLoading) {
return <IonLoading isOpen message="Loading..." />;
}
if (loading) return null; // AuthProvider already handles spinner, but double safety
if (!user) {
return <Redirect to="/auth" />;
}
const RouteComponent = Component as React.ComponentType<any>;
return <RouteComponent {...props} />;
return user ? <Component {...props} /> : <Redirect to="/auth" />;
}}
/>
);
-97
View File
@@ -1,97 +0,0 @@
import React, {
createContext,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import type { Session, User } from '@supabase/supabase-js';
import { supabase } from '../supabase';
import type { Tables } from '../database.types';
type Profile = Tables<'profiles'>;
type AuthContextValue = {
session: Session | null;
user: User | null;
profile: Profile | null;
isLoading: boolean;
refreshProfile: () => Promise<void>;
signOut: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [session, setSession] = useState<Session | null>(null);
const [user, setUser] = useState<User | null>(null);
const [profile, setProfile] = useState<Profile | null>(null);
const [isLoading, setIsLoading] = useState(true);
const loadProfile = async (userId: string | null) => {
if (!userId) {
setProfile(null);
return;
}
const { data } = await supabase
.from('profiles')
.select('*')
.eq('id', userId)
.maybeSingle();
setProfile(data ?? null);
};
useEffect(() => {
let isMounted = true;
supabase.auth.getSession().then(async ({ data }) => {
if (!isMounted) return;
const nextSession = data.session ?? null;
setSession(nextSession);
setUser(nextSession?.user ?? null);
await loadProfile(nextSession?.user?.id ?? null);
if (isMounted) setIsLoading(false);
});
const {
data: { subscription },
} = supabase.auth.onAuthStateChange(async (_event, nextSession) => {
setSession(nextSession);
setUser(nextSession?.user ?? null);
await loadProfile(nextSession?.user?.id ?? null);
setIsLoading(false);
});
return () => {
isMounted = false;
subscription.unsubscribe();
};
}, []);
const value = useMemo<AuthContextValue>(
() => ({
session,
user,
profile,
isLoading,
refreshProfile: async () => loadProfile(user?.id ?? null),
signOut: async () => {
await supabase.auth.signOut();
},
}),
[session, user, profile, isLoading]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
};
+169
View File
@@ -0,0 +1,169 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react';
import { User } from '@supabase/supabase-js';
import { supabase } from '../supabase';
import { Database } from '../database.types';
import { IonSpinner } from '@ionic/react';
type Profile = Database['public']['Tables']['profiles']['Row'];
interface AuthContextType {
user: User | null;
profile: Profile | null;
loading: boolean;
refreshProfile: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [user, setUser] = useState<User | null>(null);
const [profile, setProfile] = useState<Profile | null>(null);
const [loading, setLoading] = useState(true);
const fetchProfile = useCallback(async (currentUser: User) => {
try {
const fallbackProfile = {
id: currentUser.id,
full_name:
typeof currentUser.user_metadata?.full_name === 'string'
? currentUser.user_metadata.full_name
: '',
shop_name: 'My Shop',
currency: 'USD',
};
const { data, error } = await supabase
.from('profiles')
.upsert(fallbackProfile, { onConflict: 'id', ignoreDuplicates: true })
.select('*')
.single();
if (error) {
console.error('Error fetching profile:', error);
setProfile({
...fallbackProfile,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
});
return;
}
setProfile(data);
} catch (err) {
console.error('Error fetching profile:', err);
setProfile(null);
}
}, []);
const refreshProfile = async () => {
if (user) {
await fetchProfile(user);
}
};
useEffect(() => {
let mounted = true;
const initialiseSession = async () => {
const timeout = window.setTimeout(() => {
if (mounted) {
console.warn('Auth initialisation timed out; showing app shell.');
setLoading(false);
}
}, 8000);
try {
const {
data: { session },
error,
} = await supabase.auth.getSession();
if (!mounted) return;
if (error) {
console.error('Error restoring auth session:', error);
}
const currentUser = session?.user ?? null;
setUser(currentUser);
if (currentUser) {
await fetchProfile(currentUser);
} else {
setProfile(null);
}
} catch (err) {
console.error('Error restoring auth session:', err);
if (mounted) {
setUser(null);
setProfile(null);
}
} finally {
window.clearTimeout(timeout);
if (mounted) {
setLoading(false);
}
}
};
void initialiseSession();
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
const currentUser = session?.user ?? null;
setUser(currentUser);
if (currentUser) {
void fetchProfile(currentUser);
} else {
setProfile(null);
}
setLoading(false);
});
return () => {
mounted = false;
subscription.unsubscribe();
};
}, [fetchProfile]);
if (loading) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
background: 'var(--color-bg, #f0f4ff)',
}}
>
<IonSpinner name="crescent" color="primary" />
</div>
);
}
return (
<AuthContext.Provider value={{ user, profile, loading, refreshProfile }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
+103 -205
View File
@@ -14,272 +14,170 @@ export type Database = {
}
public: {
Tables: {
addresses: {
credit_customers: {
Row: {
address_line: string
created_at: string
id: string
is_default: boolean
label: string
name: string
notes: string | null
phone: string
recipient_name: string
user_id: string
}
Insert: {
address_line: string
created_at?: string
id?: string
is_default?: boolean
label: string
notes?: string | null
phone: string
recipient_name: string
user_id: string
}
Update: {
address_line?: string
created_at?: string
id?: string
is_default?: boolean
label?: string
notes?: string | null
phone?: string
recipient_name?: string
user_id?: string
}
Relationships: []
}
order_items: {
Row: {
created_at: string
id: string
order_id: string
product_id: string
product_name: string
quantity: number
unit_price: number
}
Insert: {
created_at?: string
id?: string
order_id: string
product_id: string
product_name: string
quantity: number
unit_price: number
}
Update: {
created_at?: string
id?: string
order_id?: string
product_id?: string
product_name?: string
quantity?: number
unit_price?: number
}
Relationships: [
{
foreignKeyName: "order_items_order_id_fkey"
columns: ["order_id"]
isOneToOne: false
referencedRelation: "orders"
referencedColumns: ["id"]
},
]
}
orders: {
Row: {
address_id: string | null
created_at: string
customer_name: string | null
customer_phone: string | null
delivery_address: string | null
delivery_fee: number
delivery_notes: string | null
id: string
item_count: number
order_number: string
status: string
subtotal: number
total: number
user_id: string | null
}
Insert: {
address_id?: string | null
created_at?: string
customer_name?: string | null
customer_phone?: string | null
delivery_address?: string | null
delivery_fee?: number
delivery_notes?: string | null
id?: string
item_count?: number
order_number: string
status?: string
subtotal?: number
total?: number
user_id?: string | null
}
Update: {
address_id?: string | null
created_at?: string
customer_name?: string | null
customer_phone?: string | null
delivery_address?: string | null
delivery_fee?: number
delivery_notes?: string | null
id?: string
item_count?: number
order_number?: string
status?: string
subtotal?: number
total?: number
user_id?: string | null
}
Relationships: [
{
foreignKeyName: "orders_address_id_fkey"
columns: ["address_id"]
isOneToOne: false
referencedRelation: "addresses"
referencedColumns: ["id"]
},
]
}
payment_methods: {
Row: {
brand: string
cardholder_name: string | null
created_at: string
expiry_month: number
expiry_year: number
id: string
is_default: boolean
last4: string
user_id: string
}
Insert: {
brand: string
cardholder_name?: string | null
created_at?: string
expiry_month: number
expiry_year: number
id?: string
is_default?: boolean
last4: string
user_id: string
}
Update: {
brand?: string
cardholder_name?: string | null
created_at?: string
expiry_month?: number
expiry_year?: number
id?: string
is_default?: boolean
last4?: string
user_id?: string
}
Relationships: []
}
profiles: {
Row: {
created_at: string
full_name: string | null
id: string
is_admin: boolean
phone: string | null
updated_at: string
user_id: string
}
Insert: {
created_at?: string
full_name?: string | null
id: string
is_admin?: boolean
id?: string
name: string
notes?: string | null
phone?: string | null
updated_at?: string
user_id: string
}
Update: {
created_at?: string
full_name?: string | null
id?: string
is_admin?: boolean
name?: string
notes?: string | null
phone?: string | null
updated_at?: string
user_id?: string
}
Relationships: []
}
wallet_transactions: {
credits: {
Row: {
amount: number
created_at: string
credit_date: string
customer_id: string
description: string | null
id: string
order_id: string | null
title: string
type: string
wallet_id: string
updated_at: string
user_id: string
}
Insert: {
amount: number
created_at?: string
credit_date?: string
customer_id: string
description?: string | null
id?: string
order_id?: string | null
title: string
type: string
wallet_id: string
updated_at?: string
user_id: string
}
Update: {
amount?: number
created_at?: string
credit_date?: string
customer_id?: string
description?: string | null
id?: string
order_id?: string | null
title?: string
type?: string
wallet_id?: string
updated_at?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "wallet_transactions_order_id_fkey"
columns: ["order_id"]
foreignKeyName: "credits_customer_id_fkey"
columns: ["customer_id"]
isOneToOne: false
referencedRelation: "orders"
referencedColumns: ["id"]
},
{
foreignKeyName: "wallet_transactions_wallet_id_fkey"
columns: ["wallet_id"]
isOneToOne: false
referencedRelation: "wallets"
referencedRelation: "credit_customers"
referencedColumns: ["id"]
},
]
}
wallets: {
profiles: {
Row: {
balance: number
created_at: string
currency: string
full_name: string | null
id: string
shop_name: string | null
updated_at: string
}
Insert: {
created_at?: string
currency?: string
full_name?: string | null
id: string
shop_name?: string | null
updated_at?: string
}
Update: {
created_at?: string
currency?: string
full_name?: string | null
id?: string
shop_name?: string | null
updated_at?: string
}
Relationships: []
}
repayments: {
Row: {
amount: number
created_at: string
customer_id: string
id: string
note: string | null
repayment_date: string
updated_at: string
user_id: string
}
Insert: {
balance?: number
amount: number
created_at?: string
currency?: string
customer_id: string
id?: string
note?: string | null
repayment_date?: string
updated_at?: string
user_id: string
}
Update: {
balance?: number
amount?: number
created_at?: string
currency?: string
customer_id?: string
id?: string
note?: string | null
repayment_date?: string
updated_at?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "repayments_customer_id_fkey"
columns: ["customer_id"]
isOneToOne: false
referencedRelation: "credit_customers"
referencedColumns: ["id"]
},
]
}
sales: {
Row: {
amount: number
created_at: string
id: string
note: string | null
sale_date: string
updated_at: string
user_id: string
}
Insert: {
amount: number
created_at?: string
id?: string
note?: string | null
sale_date?: string
updated_at?: string
user_id: string
}
Update: {
amount?: number
created_at?: string
id?: string
note?: string | null
sale_date?: string
updated_at?: string
user_id?: string
}
+10 -5
View File
@@ -5,7 +5,7 @@ import { Capacitor } from '@capacitor/core';
import App from './App';
// ── Ionic initialisation ──────────────────────────────────────────────────────
// ?mode=md|ios overrides (used by AppSuite preview); otherwise follow the platform.
// ?mode=md|ios overrides (used by Appcakes preview); otherwise follow the platform.
const _urlMode = new URLSearchParams(window.location.search).get('mode');
const _platform = Capacitor.getPlatform(); // 'ios' | 'android' | 'web'
setupIonicReact({
@@ -13,7 +13,7 @@ setupIonicReact({
});
// Derive the studio parent origin dynamically so this works in both dev
// (parent at localhost:3000) and production (parent at appcakes.qqura.com).
// (parent at localhost:3000) and production (parent at studio.appcakes.dev).
const studioOrigin = (() => {
try {
if (document.referrer) return new URL(document.referrer).origin;
@@ -49,7 +49,7 @@ if (import.meta.hot) {
}
// ── Session bridge ────────────────────────────────────────────────────────────
// Post auth session to the AppSuite parent frame so the AI can test auth-protected
// Post auth session to the Appcakes parent frame so the AI can test auth-protected
// Edge Functions. Uses import.meta.glob so Vite never errors if supabase.ts is absent.
(async () => {
const mods = import.meta.glob('./supabase.ts', { eager: false });
@@ -73,12 +73,17 @@ if (import.meta.hot) {
})();
// ── Runtime error reporting ───────────────────────────────────────────────────
// Forward uncaught errors to the AppSuite dev server so the AI can read them.
// Forward uncaught errors to the Appcakes dev server so the AI can read them.
// Extract projectId from the preview URL (/preview/{projectId}/) so errors are
// scoped per-project on the server rather than mixed into a global queue.
const _previewMatch = window.location.pathname.match(/\/preview\/([^/]+)/);
const _projectId = _previewMatch?.[1] ?? null;
function reportError(message: string, stack?: string) {
fetch(`${studioOrigin}/api/agent/runtime-error`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, stack }),
body: JSON.stringify({ message, stack, projectId: _projectId }),
}).catch(() => {});
}
-308
View File
@@ -1,308 +0,0 @@
import React, { useMemo, useState } from 'react';
import {
IonButton,
IonButtons,
IonContent,
IonHeader,
IonIcon,
IonInput,
IonItem,
IonLabel,
IonPage,
IonText,
IonToggle,
IonToolbar,
useIonViewWillEnter,
} from '@ionic/react';
import { cardOutline, chevronBackOutline } from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../context/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
const sanitizeCardNumber = (value: string) =>
value.replace(/\D/g, '').slice(0, 19);
const sanitizeExpiry = (value: string) => {
const digits = value.replace(/\D/g, '').slice(0, 4);
if (digits.length <= 2) return digits;
return `${digits.slice(0, 2)}/${digits.slice(2)}`;
};
const sanitizeCvv = (value: string) => value.replace(/\D/g, '').slice(0, 4);
const detectBrand = (cardNumber: string) => {
if (/^4/.test(cardNumber)) return 'Visa';
if (/^(5[1-5]|2[2-7])/.test(cardNumber)) return 'Mastercard';
if (/^3[47]/.test(cardNumber)) return 'Amex';
return 'Card';
};
const AddPaymentMethod: React.FC = () => {
const history = useHistory();
const { user } = useAuth();
const [cardholderName, setCardholderName] = useState('');
const [cardNumber, setCardNumber] = useState('');
const [expiry, setExpiry] = useState('');
const [cvv, setCvv] = useState('');
const [setAsDefault, setSetAsDefault] = useState(true);
const [isPreviewBack, setIsPreviewBack] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [message, setMessage] = useState('');
const [error, setError] = useState('');
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Light);
});
const showError = (text: string) => {
setError(text);
window.setTimeout(() => setError(''), 4000);
};
const formattedCardNumber = useMemo(() => {
return sanitizeCardNumber(cardNumber)
.replace(/(.{4})/g, '$1 ')
.trim();
}, [cardNumber]);
const cardBrand = detectBrand(sanitizeCardNumber(cardNumber));
const previewNumber = formattedCardNumber || '1234 5678 9010 0010';
const previewName = cardholderName.trim() || 'Andrew Johnson';
const previewExpiry = expiry || '10/22';
const previewCvv = cvv || '123';
const handleSave = async () => {
if (!user) {
history.replace('/auth');
return;
}
const cleanCardNumber = sanitizeCardNumber(cardNumber);
const cleanExpiry = sanitizeExpiry(expiry);
const [monthRaw, yearRaw] = cleanExpiry.split('/');
const month = Number(monthRaw);
const year = Number(yearRaw);
if (!cardholderName.trim()) return showError('Enter the name on card.');
if (cleanCardNumber.length < 12)
return showError('Enter a valid card number.');
if (
!month ||
month < 1 ||
month > 12 ||
String(yearRaw ?? '').length !== 2
) {
return showError('Enter a valid expiry date.');
}
if (sanitizeCvv(cvv).length < 3) return showError('Enter a valid CVV.');
setIsSubmitting(true);
setError('');
if (setAsDefault) {
await supabase
.from('payment_methods')
.update({ is_default: false })
.eq('user_id', user.id);
}
const { error: insertError } = await supabase
.from('payment_methods')
.insert({
user_id: user.id,
brand: detectBrand(cleanCardNumber),
last4: cleanCardNumber.slice(-4),
expiry_month: month,
expiry_year: 2000 + year,
cardholder_name: cardholderName.trim(),
is_default: setAsDefault,
});
setIsSubmitting(false);
if (insertError) {
showError('Could not save this payment method.');
return;
}
setMessage('Payment method saved.');
window.setTimeout(() => history.replace('/wallet/payment-methods'), 700);
};
return (
<IonPage>
<IonHeader className="ion-no-border wallet-header wallet-header-modern">
<IonToolbar>
<div className="wallet-toolbar-inner wallet-toolbar-inner-single-action">
<IonButton
fill="clear"
className="wallet-back-button wallet-back-button-modern"
aria-label="Back to payment methods"
onClick={() => history.replace('/wallet/payment-methods')}
>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
<div className="wallet-toolbar-title-block">
<span>Wallet</span>
<strong>Add card</strong>
</div>
<span className="wallet-toolbar-placeholder" />
</div>
</IonToolbar>
</IonHeader>
<IonContent fullscreen className="wallet-page add-card-page ion-padding">
<div className="wallet-intro-card add-card-intro-card">
<h1>Add Credit Card</h1>
<p>
Save a card for faster checkout. Only masked card metadata is stored
in this phase.
</p>
</div>
<div className="add-card-preview-shell">
<div className="preview-toggle-wrap">
<IonButton
fill="clear"
className="card-preview-toggle-button"
onClick={() => setIsPreviewBack((value) => !value)}
>
Show {isPreviewBack ? 'front' : 'back'} preview
</IonButton>
</div>
<div className="card-preview-stage">
<div
className={`card-preview card-preview-front ${
!isPreviewBack ? 'is-visible' : 'is-hidden'
}`}
>
<div className="card-preview-top-row">
<div className="card-chip-visual" />
<span className="card-brand-pill">{cardBrand}</span>
</div>
<div className="card-preview-number">{previewNumber}</div>
<div className="card-preview-meta-row">
<div>
<span>Cardholder Name</span>
<strong>{previewName}</strong>
</div>
<div className="card-preview-meta-right">
<span>Valid Thru</span>
<strong>{previewExpiry}</strong>
</div>
</div>
</div>
<div
className={`card-preview card-preview-back ${
isPreviewBack ? 'is-visible' : 'is-hidden'
}`}
>
<div className="card-preview-strip" />
<div className="card-preview-cvv-block">
<span>Security Code</span>
<strong>{previewCvv}</strong>
</div>
</div>
</div>
</div>
<div className="wallet-form-card add-card-form-card">
{message && (
<IonText color="success">
<p className="wallet-inline-message">{message}</p>
</IonText>
)}
{error && (
<IonText color="danger">
<p className="wallet-inline-message">{error}</p>
</IonText>
)}
<div className="checkout-form-grid add-card-form-grid">
<div className="form-field full-width">
<IonLabel>Card Number</IonLabel>
<IonItem lines="none" className="checkout-input add-card-input">
<IonIcon icon={cardOutline} slot="start" />
<IonInput
value={formattedCardNumber}
type="text"
inputMode="numeric"
placeholder="1234 5678 9012 3456"
onIonFocus={() => setIsPreviewBack(false)}
onIonInput={(e) => setCardNumber(e.detail.value ?? '')}
/>
</IonItem>
</div>
<div className="form-field">
<IonLabel>Cardholder Name</IonLabel>
<IonItem lines="none" className="checkout-input add-card-input">
<IonInput
value={cardholderName}
placeholder="Andrew Johnson"
onIonFocus={() => setIsPreviewBack(false)}
onIonInput={(e) => setCardholderName(e.detail.value ?? '')}
/>
</IonItem>
</div>
<div className="form-field">
<IonLabel>Valid Thru</IonLabel>
<IonItem lines="none" className="checkout-input add-card-input">
<IonInput
value={expiry}
type="text"
inputMode="numeric"
placeholder="10/22"
onIonFocus={() => setIsPreviewBack(false)}
onIonInput={(e) =>
setExpiry(sanitizeExpiry(e.detail.value ?? ''))
}
/>
</IonItem>
</div>
<div className="form-field full-width add-card-security-block">
<IonLabel>Security Code (CVV)</IonLabel>
<IonItem
lines="none"
className="checkout-input add-card-input add-card-cvv-input"
>
<IonInput
value={cvv}
type="password"
inputMode="numeric"
placeholder="123"
onIonFocus={() => setIsPreviewBack(true)}
onIonInput={(e) => setCvv(sanitizeCvv(e.detail.value ?? ''))}
/>
</IonItem>
</div>
</div>
<div className="wallet-toggle-row add-card-toggle-row">
<div>
<strong>Set as default</strong>
<p>Use this card first the next time you checkout.</p>
</div>
<IonToggle
checked={setAsDefault}
onIonChange={(e) => setSetAsDefault(e.detail.checked)}
/>
</div>
<IonButton
expand="block"
className="reference-place-order-button add-card-save-button"
onClick={handleSave}
disabled={isSubmitting}
>
{isSubmitting ? 'Save Card' : 'Save Card'}
</IonButton>
</div>
</IonContent>
</IonPage>
);
};
export default AddPaymentMethod;
-262
View File
@@ -1,262 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
IonButton,
IonButtons,
IonContent,
IonHeader,
IonIcon,
IonInput,
IonItem,
IonLabel,
IonList,
IonPage,
IonText,
IonTextarea,
IonToolbar,
useIonViewWillEnter,
} from '@ionic/react';
import { chevronBackOutline, trashOutline } from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../context/AuthContext';
import type { Tables } from '../database.types';
import { setStatusBarStyle, Style } from '../utils/statusBar';
type Address = Tables<'addresses'>;
const Addresses: React.FC = () => {
const history = useHistory();
const { user } = useAuth();
const [addresses, setAddresses] = useState<Address[]>([]);
const [label, setLabel] = useState('Home');
const [recipientName, setRecipientName] = useState('');
const [phone, setPhone] = useState('');
const [addressLine, setAddressLine] = useState('');
const [notes, setNotes] = useState('');
const [message, setMessage] = useState('');
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
});
const showMessage = (msg: string) => {
setMessage(msg);
window.setTimeout(() => setMessage(''), 4000);
};
const loadAddresses = async () => {
if (!user) return;
const { data } = await supabase
.from('addresses')
.select('*')
.eq('user_id', user.id)
.order('is_default', { ascending: false })
.order('created_at', { ascending: false });
setAddresses(data ?? []);
};
useEffect(() => {
loadAddresses();
}, [user?.id]);
const saveAddress = async () => {
if (
!user ||
!recipientName.trim() ||
!phone.trim() ||
!addressLine.trim()
) {
showMessage('Complete the required address details.');
return;
}
const makeDefault = addresses.length === 0;
if (makeDefault) {
await supabase
.from('addresses')
.update({ is_default: false })
.eq('user_id', user.id);
}
const { error } = await supabase.from('addresses').insert({
user_id: user.id,
label: label.trim() || 'Address',
recipient_name: recipientName.trim(),
phone: phone.trim(),
address_line: addressLine.trim(),
notes: notes.trim() || null,
is_default: makeDefault,
});
if (!error) {
setLabel('Home');
setRecipientName('');
setPhone('');
setAddressLine('');
setNotes('');
showMessage('Address saved.');
loadAddresses();
}
};
const setDefaultAddress = async (addressId: string) => {
if (!user) return;
await supabase
.from('addresses')
.update({ is_default: false })
.eq('user_id', user.id);
await supabase
.from('addresses')
.update({ is_default: true })
.eq('id', addressId);
loadAddresses();
};
const deleteAddress = async (addressId: string) => {
await supabase.from('addresses').delete().eq('id', addressId);
loadAddresses();
};
return (
<IonPage>
<IonHeader className="ion-no-border wallet-header wallet-header-modern">
<IonToolbar>
<div className="wallet-toolbar-inner wallet-toolbar-inner-single-action">
<IonButton
fill="clear"
className="wallet-back-button wallet-back-button-modern"
aria-label="Back to profile"
onClick={() => history.replace('/profile')}
>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
<div className="wallet-toolbar-title-block">
<span>Profile</span>
<strong>Addresses</strong>
</div>
<span className="wallet-toolbar-placeholder" />
</div>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
{message && (
<IonText color="medium">
<p>{message}</p>
</IonText>
)}
<div className="checkout-card">
<div className="section-heading checkout-heading">
<h2>Add address</h2>
</div>
<div className="checkout-form-grid">
<div className="form-field">
<IonLabel position="stacked">Label</IonLabel>
<IonInput
value={label}
onIonInput={(e) => setLabel(e.detail.value ?? '')}
className="checkout-input"
/>
</div>
<div className="form-field">
<IonLabel position="stacked">Recipient</IonLabel>
<IonInput
value={recipientName}
onIonInput={(e) => setRecipientName(e.detail.value ?? '')}
className="checkout-input"
/>
</div>
<div className="form-field">
<IonLabel position="stacked">Phone</IonLabel>
<IonInput
value={phone}
onIonInput={(e) => setPhone(e.detail.value ?? '')}
className="checkout-input"
/>
</div>
<div className="form-field full-width">
<IonLabel position="stacked">Address</IonLabel>
<IonTextarea
value={addressLine}
onIonInput={(e) => setAddressLine(e.detail.value ?? '')}
className="checkout-input checkout-textarea"
autoGrow
rows={3}
/>
</div>
<div className="form-field full-width">
<IonLabel position="stacked">Notes</IonLabel>
<IonTextarea
value={notes}
onIonInput={(e) => setNotes(e.detail.value ?? '')}
className="checkout-input checkout-textarea"
autoGrow
rows={3}
/>
</div>
</div>
<IonButton expand="block" onClick={saveAddress}>
Save address
</IonButton>
</div>
{addresses.length === 0 ? (
<div className="empty-state">
<h3>No saved addresses</h3>
<p className="muted-text">
Save your first address for faster checkout.
</p>
</div>
) : (
<IonList lines="none" className="orders-list">
{addresses.map((address) => (
<div key={address.id} className="order-card">
<div className="order-card-top">
<div>
<span className="order-label">{address.label}</span>
<h3>{address.recipient_name}</h3>
</div>
{address.is_default ? (
<span className="default-pill">Default</span>
) : (
<IonButton
size="small"
fill="outline"
onClick={() => setDefaultAddress(address.id)}
>
Make default
</IonButton>
)}
</div>
<div className="delivery-detail-row delivery-detail-block">
<span>Phone</span>
<strong>{address.phone}</strong>
</div>
<div className="delivery-detail-row delivery-detail-block">
<span>Address</span>
<strong>{address.address_line}</strong>
</div>
{address.notes && (
<div className="delivery-detail-row delivery-detail-block">
<span>Notes</span>
<strong>{address.notes}</strong>
</div>
)}
<IonButton
fill="clear"
color="danger"
onClick={() => deleteAddress(address.id)}
>
<IonIcon slot="start" icon={trashOutline} />
Delete
</IonButton>
</div>
))}
</IonList>
)}
</IonContent>
</IonPage>
);
};
export default Addresses;
-123
View File
@@ -1,123 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
IonButton,
IonContent,
IonHeader,
IonIcon,
IonItem,
IonLabel,
IonList,
IonPage,
IonSelect,
IonSelectOption,
IonToolbar,
useIonViewWillEnter,
} from '@ionic/react';
import { chevronBackOutline } from 'ionicons/icons';
import { RouteComponentProps, useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import type { Tables } from '../database.types';
import { setStatusBarStyle, Style } from '../utils/statusBar';
type Props = RouteComponentProps<{ id: string }>;
type Order = Tables<'orders'>;
type OrderItem = Tables<'order_items'>;
const statuses = ['confirmed', 'packed', 'out_for_delivery', 'delivered'];
const AdminOrderDetail: React.FC<Props> = ({ match }) => {
const history = useHistory();
const [order, setOrder] = useState<Order | null>(null);
const [items, setItems] = useState<OrderItem[]>([]);
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
});
const loadOrder = async () => {
const [{ data: orderData }, { data: itemData }] = await Promise.all([
supabase.from('orders').select('*').eq('id', match.params.id).single(),
supabase.from('order_items').select('*').eq('order_id', match.params.id),
]);
setOrder(orderData ?? null);
setItems(itemData ?? []);
};
useEffect(() => {
loadOrder();
}, [match.params.id]);
const updateStatus = async (status: string) => {
await supabase.from('orders').update({ status }).eq('id', match.params.id);
loadOrder();
};
if (!order)
return (
<IonPage>
<IonContent />
</IonPage>
);
return (
<IonPage>
<IonHeader className="ion-no-border wallet-header wallet-header-modern">
<IonToolbar>
<div className="wallet-toolbar-inner wallet-toolbar-inner-single-action">
<IonButton
fill="clear"
className="wallet-back-button wallet-back-button-modern"
aria-label="Back to admin orders"
onClick={() => history.replace('/admin/orders')}
>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
<div className="wallet-toolbar-title-block">
<span>Admin order</span>
<strong>{order.order_number}</strong>
</div>
<span className="wallet-toolbar-placeholder" />
</div>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<div className="tracking-summary-card">
<div className="delivery-detail-row">
<span>Customer</span>
<strong>{order.customer_name || '—'}</strong>
</div>
<div className="delivery-detail-row">
<span>Phone</span>
<strong>{order.customer_phone || '—'}</strong>
</div>
<div className="delivery-detail-row delivery-detail-block">
<span>Address</span>
<strong>{order.delivery_address || '—'}</strong>
</div>
<IonSelect
value={order.status}
interface="action-sheet"
onIonChange={(e) => updateStatus(e.detail.value)}
>
{statuses.map((status) => (
<IonSelectOption key={status} value={status}>
{status.replace(/_/g, ' ')}
</IonSelectOption>
))}
</IonSelect>
</div>
<IonList lines="none" className="cart-list">
{items.map((item) => (
<IonItem key={item.id} className="cart-item">
<IonLabel>
<h2>{item.product_name}</h2>
<p>Qty {item.quantity}</p>
</IonLabel>
</IonItem>
))}
</IonList>
</IonContent>
</IonPage>
);
};
export default AdminOrderDetail;
-109
View File
@@ -1,109 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
IonButton,
IonContent,
IonHeader,
IonIcon,
IonItem,
IonLabel,
IonList,
IonPage,
IonSelect,
IonSelectOption,
IonToolbar,
useIonViewWillEnter,
} from '@ionic/react';
import { chevronBackOutline } from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import type { Tables } from '../database.types';
import { setStatusBarStyle, Style } from '../utils/statusBar';
type Order = Tables<'orders'>;
const statuses = ['confirmed', 'packed', 'out_for_delivery', 'delivered'];
const AdminOrders: React.FC = () => {
const history = useHistory();
const [orders, setOrders] = useState<Order[]>([]);
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
});
const loadOrders = async () => {
const { data } = await supabase
.from('orders')
.select('*')
.order('created_at', { ascending: false });
setOrders(data ?? []);
};
useEffect(() => {
loadOrders();
}, []);
const updateStatus = async (orderId: string, status: string) => {
await supabase.from('orders').update({ status }).eq('id', orderId);
loadOrders();
};
return (
<IonPage>
<IonHeader className="ion-no-border wallet-header wallet-header-modern">
<IonToolbar>
<div className="wallet-toolbar-inner wallet-toolbar-inner-single-action">
<IonButton
fill="clear"
className="wallet-back-button wallet-back-button-modern"
aria-label="Back to profile"
onClick={() => history.replace('/profile')}
>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
<div className="wallet-toolbar-title-block">
<span>Admin</span>
<strong>Orders</strong>
</div>
<span className="wallet-toolbar-placeholder" />
</div>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<IonList lines="none" className="orders-list">
{orders.map((order) => (
<IonItem
key={order.id}
className="cart-item admin-order-item"
button
onClick={() => history.push(`/admin/orders/${order.id}`)}
>
<IonLabel>
<h2>{order.order_number}</h2>
<p>
{order.customer_name || 'Customer'} · $
{Number(order.total).toFixed(2)}
</p>
</IonLabel>
<IonSelect
value={order.status}
interface="popover"
onIonChange={(e) => {
e.stopPropagation();
updateStatus(order.id, e.detail.value);
}}
>
{statuses.map((status) => (
<IonSelectOption key={status} value={status}>
{status.replace(/_/g, ' ')}
</IonSelectOption>
))}
</IonSelect>
</IonItem>
))}
</IonList>
</IonContent>
</IonPage>
);
};
export default AdminOrders;
-225
View File
@@ -1,225 +0,0 @@
import React, { useState } from 'react';
import {
IonButton,
IonContent,
IonHeader,
IonInput,
IonItem,
IonLabel,
IonPage,
IonText,
IonTitle,
IonToolbar,
IonToggle,
useIonViewWillEnter,
} from '@ionic/react';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { setStatusBarStyle, Style } from '../utils/statusBar';
const Auth: React.FC = () => {
const history = useHistory();
const [mode, setMode] = useState<'login' | 'signup'>('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [message, setMessage] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [demoMode, setDemoMode] = useState(true);
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Light);
});
const showError = (msg: string) => {
setErrorMessage(msg);
window.setTimeout(() => setErrorMessage(''), 4000);
};
const showMessage = (msg: string) => {
setMessage(msg);
window.setTimeout(() => setMessage(''), 4000);
};
const normaliseAuthError = (msg: string) => {
const lowered = msg.toLowerCase();
if (lowered.includes('email not confirmed')) {
return 'Please confirm your email before signing in. Check your inbox for the verification email.';
}
if (lowered.includes('invalid login credentials')) {
return 'Incorrect email or password. Please try again.';
}
if (lowered.includes('user already registered')) {
return 'An account with this email already exists. Please sign in instead.';
}
return msg;
};
const handleResendConfirmation = async () => {
if (!email.trim()) {
showError(
'Enter your email address first so we can resend the confirmation email.'
);
return;
}
setIsSubmitting(true);
const { error } = await supabase.auth.resend({
type: 'signup',
email: email.trim(),
});
setIsSubmitting(false);
if (error) {
showError(normaliseAuthError(error.message));
return;
}
showMessage('Confirmation email sent. Please check your inbox.');
};
const handleSubmit = async () => {
if (demoMode && mode === 'login') {
history.replace('/wallet');
return;
}
if (!email.trim() || password.trim().length < 6) {
showError('Enter a valid email and password with at least 6 characters.');
return;
}
setIsSubmitting(true);
if (mode === 'login') {
const { error } = await supabase.auth.signInWithPassword({
email: email.trim(),
password,
});
setIsSubmitting(false);
if (error) {
showError(normaliseAuthError(error.message));
return;
}
history.replace('/home');
return;
}
const { data, error } = await supabase.auth.signUp({
email: email.trim(),
password,
});
setIsSubmitting(false);
if (error) {
showError(normaliseAuthError(error.message));
return;
}
if (data.session) {
history.replace('/home');
return;
}
showMessage('Account created! Check your email for a verification link.');
};
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar>
<IonTitle>
{mode === 'login' ? 'Welcome back' : 'Create account'}
</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding auth-page">
<div className="auth-card">
<h1>
{mode === 'login'
? 'Sign in to continue'
: 'Create your shopping account'}
</h1>
<p className="muted-text">
Save your orders, addresses, and track deliveries securely.
</p>
{errorMessage && (
<IonText color="danger">
<p>{errorMessage}</p>
</IonText>
)}
{message && (
<IonText color="success">
<p>{message}</p>
</IonText>
)}
<div className="auth-form">
<IonItem lines="none" className="auth-input-item auth-toggle-item">
<IonLabel>Demo quick access</IonLabel>
<IonToggle
checked={demoMode}
onIonChange={(e) => setDemoMode(e.detail.checked)}
/>
</IonItem>
<IonText className="auth-helper-text">
<p>
When enabled, Sign in opens the app immediately so you can
review the full experience without authentication.
</p>
</IonText>
<IonItem lines="none" className="auth-input-item">
<IonLabel position="stacked">Email</IonLabel>
<IonInput
value={email}
type="email"
disabled={demoMode && mode === 'login'}
onIonInput={(e) => setEmail(e.detail.value ?? '')}
/>
</IonItem>
<IonItem lines="none" className="auth-input-item">
<IonLabel position="stacked">Password</IonLabel>
<IonInput
value={password}
type="password"
disabled={demoMode && mode === 'login'}
onIonInput={(e) => setPassword(e.detail.value ?? '')}
/>
</IonItem>
<IonButton
expand="block"
onClick={handleSubmit}
disabled={isSubmitting}
>
{mode === 'login' ? 'Sign in' : 'Create account'}
</IonButton>
{mode === 'login' && (
<IonButton
fill="clear"
expand="block"
onClick={handleResendConfirmation}
disabled={isSubmitting}
>
Resend confirmation email
</IonButton>
)}
<IonButton
fill="clear"
expand="block"
onClick={() => setMode(mode === 'login' ? 'signup' : 'login')}
>
{mode === 'login'
? 'Need an account? Sign up'
: 'Already have an account? Sign in'}
</IonButton>
</div>
</div>
</IonContent>
</IonPage>
);
};
export default Auth;
+233
View File
@@ -0,0 +1,233 @@
import React, { useState } from 'react';
import {
IonContent,
IonPage,
IonInput,
IonButton,
IonSpinner,
IonIcon,
IonText,
} from '@ionic/react';
import { storefrontOutline } from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
const AuthPage: React.FC = () => {
const [mode, setMode] = useState<'signin' | 'signup'>('signin');
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [messageType, setMessageType] = useState<'error' | 'success'>('error');
const history = useHistory();
const showMessage = (msg: string, type: 'error' | 'success' = 'error') => {
setMessage(msg);
setMessageType(type);
setTimeout(() => setMessage(null), 4000);
};
const handleAuth = async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !password || (mode === 'signup' && !name)) {
showMessage('Please fill in all fields');
return;
}
setSubmitting(true);
setMessage(null);
try {
if (mode === 'signup') {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
data: {
full_name: name,
},
},
});
if (error) throw error;
if (data.session) {
history.push('/home');
} else {
history.push('/verify-email', { email });
}
} else {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
if (error.message.toLowerCase().includes('email not confirmed')) {
await supabase.auth.resend({ type: 'signup', email });
history.push('/verify-email', { email, resent: true });
return;
}
throw error;
}
if (data.session) {
history.push('/home');
}
}
} catch (err: any) {
showMessage(err.message || 'An authentication error occurred');
} finally {
setSubmitting(false);
}
};
return (
<IonPage>
<IonContent
fullscreen
style={{
'--background':
'linear-gradient(160deg, rgba(199,210,254,0.52) 0%, #f0f4ff 58%, #f8fafc 100%)',
}}
>
<div className="auth-shell">
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
marginTop: '20px',
}}
>
<div
className="icon-container"
style={{ width: '44px', height: '44px' }}
>
<IonIcon icon={storefrontOutline} style={{ fontSize: '22px' }} />
</div>
<div>
<h1 className="bold" style={{ margin: '0', fontSize: '22px' }}>
ShopKeep
</h1>
<p className="secondary-text" style={{ margin: '0' }}>
Simple bookkeeping for you
</p>
</div>
</div>
<div className="auth-card">
<div className="pill-toggle">
<button
className={`pill-toggle-btn ${mode === 'signin' ? 'active' : ''}`}
onClick={() => setMode('signin')}
>
Sign in
</button>
<button
className={`pill-toggle-btn ${mode === 'signup' ? 'active' : ''}`}
onClick={() => setMode('signup')}
>
Create account
</button>
</div>
<form
onSubmit={handleAuth}
style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}
>
{mode === 'signup' && (
<div className="input-wrapper">
<IonInput
label="Full Name"
labelPlacement="floating"
placeholder="Enter your name"
value={name}
onIonChange={(e) => setName(e.detail.value!)}
/>
</div>
)}
<div className="input-wrapper">
<IonInput
label="Email"
labelPlacement="floating"
type="email"
placeholder="name@example.com"
value={email}
onIonChange={(e) => setEmail(e.detail.value!)}
/>
</div>
<div className="input-wrapper">
<IonInput
label="Password"
labelPlacement="floating"
type="password"
placeholder="Min. 6 characters"
value={password}
onIonChange={(e) => setPassword(e.detail.value!)}
/>
</div>
{message && (
<div
style={{
background:
messageType === 'error'
? 'var(--color-danger-soft)'
: 'var(--color-success-soft)',
color:
messageType === 'error'
? 'var(--ion-color-danger-shade)'
: 'var(--ion-color-success-shade)',
padding: '12px',
borderRadius: '12px',
fontSize: '13px',
}}
>
{message}
</div>
)}
<IonButton
type="submit"
expand="block"
className="bold"
style={{
height: '52px',
'--border-radius': 'var(--radius-pill)',
marginTop: '8px',
}}
disabled={submitting}
>
{submitting ? (
<IonSpinner name="crescent" />
) : mode === 'signin' ? (
'Sign in'
) : (
'Create account'
)}
</IonButton>
</form>
<div style={{ textAlign: 'center', marginTop: '8px' }}>
<IonText
color="primary"
className="semibold"
style={{ fontSize: '13px', cursor: 'pointer' }}
onClick={() => history.push('/forgot-password')}
>
Forgot password?
</IonText>
</div>
</div>
</div>
</IonContent>
</IonPage>
);
};
export default AuthPage;
-529
View File
@@ -1,529 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
IonButton,
IonButtons,
IonContent,
IonHeader,
IonIcon,
IonInput,
IonItem,
IonLabel,
IonList,
IonLoading,
IonPage,
IonSelect,
IonSelectOption,
IonTextarea,
IonText,
IonTitle,
IonToolbar,
useIonViewWillEnter,
} from '@ionic/react';
import {
addOutline,
bagHandleOutline,
chevronBackOutline,
removeOutline,
timeOutline,
trashOutline,
} from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import type { Tables, TablesInsert } from '../database.types';
import { useAuth } from '../context/AuthContext';
import { CartItem, LAST_ORDER_STORAGE_KEY, readCart, writeCart } from './Home';
import { setStatusBarStyle, Style } from '../utils/statusBar';
type Address = Tables<'addresses'>;
type CheckoutForm = {
customerName: string;
customerPhone: string;
deliveryAddress: string;
deliveryNotes: string;
addressId: string;
};
type FormErrors = Partial<Record<keyof CheckoutForm, string>>;
type SavedOrderInfo = {
id: string;
orderNumber: string;
total: number;
itemCount: number;
status: string;
customerName: string;
customerPhone: string;
deliveryAddress: string;
deliveryNotes: string;
};
const initialForm: CheckoutForm = {
customerName: '',
customerPhone: '',
deliveryAddress: '',
deliveryNotes: '',
addressId: '',
};
const Cart: React.FC = () => {
const history = useHistory();
const { user, profile } = useAuth();
const [cart, setCart] = useState<CartItem[]>([]);
const [addresses, setAddresses] = useState<Address[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [form, setForm] = useState<CheckoutForm>(initialForm);
const [formErrors, setFormErrors] = useState<FormErrors>({});
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
});
useEffect(() => {
setCart(readCart());
}, []);
useEffect(() => {
const syncCartFromStorage = () => setCart(readCart());
window.addEventListener('storage', syncCartFromStorage);
window.addEventListener(
'cart:updated',
syncCartFromStorage as EventListener
);
return () => {
window.removeEventListener('storage', syncCartFromStorage);
window.removeEventListener(
'cart:updated',
syncCartFromStorage as EventListener
);
};
}, []);
useEffect(() => {
const loadAddresses = async () => {
if (!user) {
setAddresses([]);
setForm((current) => ({
...current,
customerName: current.customerName || profile?.full_name || '',
customerPhone: current.customerPhone || profile?.phone || '',
addressId: '',
}));
return;
}
const { data } = await supabase
.from('addresses')
.select('*')
.eq('user_id', user.id)
.order('is_default', { ascending: false });
const nextAddresses = data ?? [];
setAddresses(nextAddresses);
const defaultAddress =
nextAddresses.find((address) => address.is_default) ?? nextAddresses[0];
setForm((current) => ({
...current,
customerName:
current.customerName ||
profile?.full_name ||
defaultAddress?.recipient_name ||
'',
customerPhone:
current.customerPhone ||
profile?.phone ||
defaultAddress?.phone ||
'',
deliveryAddress:
current.deliveryAddress || defaultAddress?.address_line || '',
deliveryNotes: current.deliveryNotes || defaultAddress?.notes || '',
addressId: defaultAddress?.id || '',
}));
};
void loadAddresses();
}, [user?.id, profile?.full_name, profile?.phone]);
const syncCart = (nextCart: CartItem[]) => {
setCart(nextCart);
writeCart(nextCart);
};
const showError = (message: string) => {
setErrorMessage(message);
window.setTimeout(() => setErrorMessage(''), 4000);
};
const handleFieldChange = (field: keyof CheckoutForm, value: string) => {
setForm((current) => ({ ...current, [field]: value }));
if (formErrors[field]) {
setFormErrors((current) => ({ ...current, [field]: undefined }));
}
};
const handleAddressSelect = (addressId: string) => {
const selected = addresses.find((address) => address.id === addressId);
if (!selected) return;
setForm((current) => ({
...current,
addressId,
customerName: selected.recipient_name,
customerPhone: selected.phone,
deliveryAddress: selected.address_line,
deliveryNotes: selected.notes || '',
}));
};
const validateForm = () => {
const nextErrors: FormErrors = {};
if (!form.customerName.trim()) {
nextErrors.customerName = 'Please enter the customer name.';
}
if (form.customerPhone.trim().replace(/\D/g, '').length < 7) {
nextErrors.customerPhone = 'Please enter a valid phone number.';
}
if (form.deliveryAddress.trim().length < 10) {
nextErrors.deliveryAddress =
'Please add a more complete delivery address.';
}
setFormErrors(nextErrors);
return Object.keys(nextErrors).length === 0;
};
const itemCount = useMemo(
() => cart.reduce((sum, item) => sum + item.quantity, 0),
[cart]
);
const subtotal = useMemo(
() => cart.reduce((sum, item) => sum + item.price * item.quantity, 0),
[cart]
);
const deliveryFee = cart.length > 0 ? 2.99 : 0;
const total = subtotal + deliveryFee;
const updateQuantity = (id: string, delta: number) =>
syncCart(
cart
.map((item) =>
item.id === id ? { ...item, quantity: item.quantity + delta } : item
)
.filter((item) => item.quantity > 0)
);
const removeItem = (id: string) =>
syncCart(cart.filter((item) => item.id !== id));
const placeOrder = async () => {
if (cart.length === 0 || isSubmitting) return;
if (!validateForm()) {
showError('Please complete the checkout form before placing the order.');
return;
}
setIsSubmitting(true);
const orderNumber = `QC-${Date.now().toString().slice(-6)}`;
const orderPayload: TablesInsert<'orders'> = {
order_number: orderNumber,
subtotal,
delivery_fee: deliveryFee,
total,
item_count: itemCount,
status: 'confirmed',
customer_name: form.customerName.trim(),
customer_phone: form.customerPhone.trim(),
delivery_address: form.deliveryAddress.trim(),
delivery_notes: form.deliveryNotes.trim() || null,
user_id: user?.id ?? null,
address_id: user ? form.addressId || null : null,
};
const { data: order, error: orderError } = await supabase
.from('orders')
.insert(orderPayload)
.select()
.single();
if (orderError || !order) {
setIsSubmitting(false);
showError('We could not place your order. Please try again.');
return;
}
const itemsPayload: TablesInsert<'order_items'>[] = cart.map((item) => ({
order_id: order.id,
product_id: item.id,
product_name: item.name,
unit_price: item.price,
quantity: item.quantity,
}));
const { error: itemsError } = await supabase
.from('order_items')
.insert(itemsPayload);
if (itemsError) {
await supabase.from('orders').delete().eq('id', order.id);
setIsSubmitting(false);
showError('We could not save your order items. Please try again.');
return;
}
const savedOrder: SavedOrderInfo = {
id: order.id,
orderNumber: order.order_number,
total: Number(order.total),
itemCount: order.item_count,
status: order.status,
customerName: order.customer_name ?? '',
customerPhone: order.customer_phone ?? '',
deliveryAddress: order.delivery_address ?? '',
deliveryNotes: order.delivery_notes ?? '',
};
localStorage.setItem(LAST_ORDER_STORAGE_KEY, JSON.stringify(savedOrder));
writeCart([]);
setCart([]);
setIsSubmitting(false);
history.replace('/order-confirmation');
};
return (
<IonPage>
<IonHeader className="ion-no-border wallet-header wallet-header-modern">
<IonToolbar>
<div className="wallet-toolbar-inner wallet-toolbar-inner-single-action">
<IonButton
fill="clear"
className="wallet-back-button wallet-back-button-modern"
aria-label="Back to home"
onClick={() => history.replace('/home')}
>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
<div className="wallet-toolbar-title-block">
<span>Checkout</span>
<strong>Basket</strong>
</div>
<span className="wallet-toolbar-placeholder" />
</div>
</IonToolbar>
</IonHeader>
<IonContent fullscreen className="hybrid-page-surface ion-padding">
<IonLoading isOpen={isSubmitting} message="Placing your order..." />
{errorMessage && (
<IonText color="danger">
<p>{errorMessage}</p>
</IonText>
)}
{cart.length === 0 ? (
<div className="empty-state reference-empty-state cart-empty">
<IonIcon icon={bagHandleOutline} />
<h3>Your basket is empty</h3>
<p className="muted-text">
Add your first product to place an order.
</p>
<IonButton onClick={() => history.push('/home')}>
Browse products
</IonButton>
</div>
) : (
<div className="reference-cart-layout">
<div className="reference-cart-summary-strip">
<div>
<p className="overview-label">Items in basket</p>
<h2>
{itemCount} item{itemCount === 1 ? '' : 's'}
</h2>
</div>
<div className="summary-time-pill">
<IonIcon icon={timeOutline} />
<span>Arrives in 60 min</span>
</div>
</div>
<IonList lines="none" className="cart-list reference-cart-list">
{cart.map((item) => (
<IonItem
key={item.id}
className="cart-item reference-cart-item"
>
<IonLabel>
<h2>{item.name}</h2>
<p>${item.price.toFixed(2)} each</p>
<strong>${(item.price * item.quantity).toFixed(2)}</strong>
</IonLabel>
<div className="cart-actions reference-cart-actions">
<IonButton
fill="clear"
onClick={() => updateQuantity(item.id, -1)}
>
<IonIcon icon={removeOutline} />
</IonButton>
<span>{item.quantity}</span>
<IonButton
fill="clear"
onClick={() => updateQuantity(item.id, 1)}
>
<IonIcon icon={addOutline} />
</IonButton>
<IonButton
fill="clear"
color="danger"
onClick={() => removeItem(item.id)}
>
<IonIcon icon={trashOutline} />
</IonButton>
</div>
</IonItem>
))}
</IonList>
<div className="checkout-card reference-checkout-card">
<div className="section-heading checkout-heading">
<div>
<h2>Delivery details</h2>
<IonText color="medium">
<p>Complete this to confirm your order</p>
</IonText>
</div>
</div>
{user && addresses.length > 0 && (
<div className="form-field full-width">
<IonLabel position="stacked">Saved address</IonLabel>
<IonSelect
value={form.addressId}
placeholder="Choose a saved address"
className="checkout-input"
onIonChange={(e) => handleAddressSelect(e.detail.value)}
>
{addresses.map((address) => (
<IonSelectOption key={address.id} value={address.id}>
{address.label} · {address.address_line}
</IonSelectOption>
))}
</IonSelect>
</div>
)}
<div className="checkout-form-grid">
<div className="form-field">
<IonLabel position="stacked">Customer name</IonLabel>
<IonInput
value={form.customerName}
placeholder="Enter full name"
className="checkout-input"
onIonInput={(e) =>
handleFieldChange('customerName', e.detail.value ?? '')
}
/>
{formErrors.customerName && (
<IonText color="danger" className="field-error">
<p>{formErrors.customerName}</p>
</IonText>
)}
</div>
<div className="form-field">
<IonLabel position="stacked">Phone number</IonLabel>
<IonInput
value={form.customerPhone}
type="tel"
inputmode="tel"
placeholder="e.g. +263 77 123 4567"
className="checkout-input"
onIonInput={(e) =>
handleFieldChange('customerPhone', e.detail.value ?? '')
}
/>
{formErrors.customerPhone && (
<IonText color="danger" className="field-error">
<p>{formErrors.customerPhone}</p>
</IonText>
)}
</div>
<div className="form-field full-width">
<IonLabel position="stacked">Delivery address</IonLabel>
<IonTextarea
value={form.deliveryAddress}
autoGrow
rows={3}
placeholder="House number, street, suburb, city"
className="checkout-input checkout-textarea"
onIonInput={(e) =>
handleFieldChange('deliveryAddress', e.detail.value ?? '')
}
/>
{formErrors.deliveryAddress && (
<IonText color="danger" className="field-error">
<p>{formErrors.deliveryAddress}</p>
</IonText>
)}
</div>
<div className="form-field full-width">
<IonLabel position="stacked">Delivery notes</IonLabel>
<IonTextarea
value={form.deliveryNotes}
autoGrow
rows={3}
placeholder="Gate code, landmark, or helpful instructions"
className="checkout-input checkout-textarea"
onIonInput={(e) =>
handleFieldChange('deliveryNotes', e.detail.value ?? '')
}
/>
</div>
</div>
{user && (
<IonButton
fill="outline"
expand="block"
className="detail-secondary-button"
onClick={() => history.push('/addresses')}
>
Manage saved addresses
</IonButton>
)}
</div>
<div className="summary-card reference-order-total-card">
<div className="summary-row">
<IonText color="medium">Items</IonText>
<strong>{itemCount}</strong>
</div>
<div className="summary-row">
<IonText color="medium">Subtotal</IonText>
<strong>${subtotal.toFixed(2)}</strong>
</div>
<div className="summary-row">
<IonText color="medium">Delivery</IonText>
<strong>${deliveryFee.toFixed(2)}</strong>
</div>
<div className="summary-row total-row">
<span>Total</span>
<strong>${total.toFixed(2)}</strong>
</div>
<IonButton
expand="block"
className="reference-place-order-button"
onClick={placeOrder}
disabled={isSubmitting}
>
Place order
</IonButton>
</div>
</div>
)}
</IonContent>
</IonPage>
);
};
export default Cart;
+359
View File
@@ -0,0 +1,359 @@
import React, { useState, useEffect } from 'react';
import {
IonContent,
IonPage,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonButton,
IonIcon,
IonInput,
IonTextarea,
IonSpinner,
IonLabel,
IonSelect,
IonSelectOption,
} from '@ionic/react';
import { chevronBackOutline } from 'ionicons/icons';
import { useHistory, useLocation } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { getTodayISO } from '../utils/dates';
import MoneyInput from '../components/MoneyInput';
import { parseMoney } from '../utils/money';
interface Customer {
id: string;
name: string;
}
const CreditFormPage: React.FC = () => {
const history = useHistory();
const location = useLocation<{ customerId?: string }>();
const { user } = useAuth();
const [mode, setMode] = useState<'existing' | 'new'>('existing');
const [customers, setCustomers] = useState<Customer[]>([]);
const [selectedCustomerId, setSelectedCustomerId] = useState<string>(
location.state?.customerId || ''
);
const [newName, setNewName] = useState('');
const [phone, setPhone] = useState('');
const [amount, setAmount] = useState('');
const [creditDate, setCreditDate] = useState(getTodayISO());
const [description, setDescription] = useState('');
const [submitting, setSubmitting] = useState(false);
const [loadingCustomers, setLoadingCustomers] = useState(true);
const [errors, setErrors] = useState<Record<string, string>>({});
const [message, setMessage] = useState<string | null>(null);
useEffect(() => {
const fetchCustomers = async () => {
if (!user) return;
try {
const { data, error } = await supabase
.from('credit_customers')
.select('id, name')
.eq('user_id', user.id)
.order('name');
if (error) throw error;
setCustomers(data || []);
if (!data || data.length === 0) {
setMode('new');
}
} catch (err) {
console.error('Error fetching customers:', err);
} finally {
setLoadingCustomers(false);
}
};
fetchCustomers();
}, [user]);
const validate = () => {
const newErrors: Record<string, string> = {};
const parsedAmount = parseMoney(amount);
if (mode === 'existing' && !selectedCustomerId) {
newErrors.customer = 'Please select a customer';
}
if (mode === 'new' && !newName.trim()) {
newErrors.name = 'Customer name is required';
}
if (parsedAmount <= 0) {
newErrors.amount = 'Amount must be greater than 0';
}
if (!creditDate) {
newErrors.date = 'Date is required';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSave = async () => {
if (!validate() || !user) return;
setSubmitting(true);
setMessage(null);
try {
let customerId = selectedCustomerId;
if (mode === 'new') {
const { data: newCustomer, error: customerError } = await supabase
.from('credit_customers')
.insert([
{
user_id: user.id,
name: newName.trim(),
phone: phone.trim() || null,
},
])
.select()
.single();
if (customerError) throw customerError;
customerId = newCustomer.id;
}
const { error: creditError } = await supabase.from('credits').insert([
{
user_id: user.id,
customer_id: customerId,
amount: parseMoney(amount),
credit_date: creditDate,
description: description.trim() || null,
},
]);
if (creditError) throw creditError;
history.goBack();
} catch (err: any) {
console.error('Error saving credit:', err);
setMessage(err.message || 'Could not save credit');
setTimeout(() => setMessage(null), 4000);
} finally {
setSubmitting(false);
}
};
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': '#f0f4ff' }}>
<IonButtons slot="start">
<IonButton color="dark" onClick={() => history.goBack()}>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
<IonTitle className="bold">Add credit</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding" style={{ '--background': '#f0f4ff' }}>
<div className="form-card">
<div className="pill-toggle" style={{ marginBottom: '8px' }}>
<button
className={`pill-toggle-btn ${mode === 'existing' ? 'active' : ''}`}
onClick={() => setMode('existing')}
disabled={customers.length === 0}
>
Existing Customer
</button>
<button
className={`pill-toggle-btn ${mode === 'new' ? 'active' : ''}`}
onClick={() => setMode('new')}
>
New Customer
</button>
</div>
{mode === 'existing' ? (
<div className="form-field">
<IonLabel
className="semibold"
style={{
marginBottom: '8px',
display: 'block',
fontSize: '13px',
}}
>
Select Customer
</IonLabel>
<div className="input-wrapper">
<IonSelect
interface="action-sheet"
placeholder="Choose a customer"
value={selectedCustomerId}
onIonChange={(e) => setSelectedCustomerId(e.detail.value)}
style={{ width: '100%' }}
>
{customers.map((c) => (
<IonSelectOption key={c.id} value={c.id}>
{c.name}
</IonSelectOption>
))}
</IonSelect>
</div>
{errors.customer && (
<IonLabel
color="danger"
style={{ fontSize: '12px', marginTop: '4px' }}
>
{errors.customer}
</IonLabel>
)}
</div>
) : (
<>
<div className="form-field">
<IonLabel
className="semibold"
style={{
marginBottom: '8px',
display: 'block',
fontSize: '13px',
}}
>
Customer Name
</IonLabel>
<div className="input-wrapper">
<IonInput
placeholder="e.g. John Doe"
value={newName}
onIonChange={(e) => setNewName(e.detail.value!)}
/>
</div>
{errors.name && (
<IonLabel
color="danger"
style={{ fontSize: '12px', marginTop: '4px' }}
>
{errors.name}
</IonLabel>
)}
</div>
<div className="form-field">
<IonLabel
className="semibold"
style={{
marginBottom: '8px',
display: 'block',
fontSize: '13px',
}}
>
Phone (optional)
</IonLabel>
<div className="input-wrapper">
<IonInput
type="tel"
placeholder="e.g. +1 234 567 890"
value={phone}
onIonChange={(e) => setPhone(e.detail.value!)}
/>
</div>
</div>
</>
)}
<MoneyInput
label="Credit amount"
value={amount}
onValueChange={(val) => {
setAmount(val);
if (errors.amount) setErrors({ ...errors, amount: '' });
}}
error={errors.amount}
/>
<div className="form-field">
<IonLabel
className="semibold"
style={{
marginBottom: '8px',
display: 'block',
fontSize: '13px',
}}
>
Credit date
</IonLabel>
<div className="input-wrapper">
<IonInput
type="date"
value={creditDate}
onIonChange={(e) => setCreditDate(e.detail.value!)}
/>
</div>
{errors.date && (
<IonLabel
color="danger"
style={{ fontSize: '12px', marginTop: '4px' }}
>
{errors.date}
</IonLabel>
)}
</div>
<div className="form-field">
<IonLabel
className="semibold"
style={{
marginBottom: '8px',
display: 'block',
fontSize: '13px',
}}
>
Description (optional)
</IonLabel>
<div
className="input-wrapper"
style={{ minHeight: '96px', padding: '8px 16px' }}
>
<IonTextarea
placeholder="e.g. 2 bags of rice, sugar"
value={description}
onIonChange={(e) => setDescription(e.detail.value!)}
rows={4}
/>
</div>
</div>
{message && (
<div
style={{
background: 'var(--color-danger-soft)',
color: 'var(--ion-color-danger-shade)',
padding: '12px',
borderRadius: '12px',
fontSize: '13px',
}}
>
{message}
</div>
)}
<IonButton
expand="block"
className="bold"
style={{
height: '52px',
'--border-radius': 'var(--radius-pill)',
marginTop: '16px',
}}
disabled={submitting || (loadingCustomers && mode === 'existing')}
onClick={handleSave}
>
{submitting ? <IonSpinner name="crescent" /> : 'Save credit'}
</IonButton>
</div>
</IonContent>
</IonPage>
);
};
export default CreditFormPage;
+361
View File
@@ -0,0 +1,361 @@
import React, { useState, useCallback, useMemo } from 'react';
import {
IonContent,
IonPage,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonButton,
IonIcon,
IonRefresher,
IonRefresherContent,
IonAlert,
useIonViewWillEnter,
} from '@ionic/react';
import {
chevronBackOutline,
trashOutline,
receiptOutline,
personAddOutline,
cashOutline,
} from 'ionicons/icons';
import { useHistory, useParams } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { supabase } from '../supabase';
import ActivityRow, { ActivityItem } from '../components/ActivityRow';
import EmptyState from '../components/EmptyState';
import { formatMoney } from '../utils/money';
const CustomerDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const { user, profile } = useAuth();
const history = useHistory();
const currency = profile?.currency || 'USD';
const [loading, setLoading] = useState(true);
const [customer, setCustomer] = useState<any>(null);
const [activity, setActivity] = useState<ActivityItem[]>([]);
const [balance, setBalance] = useState(0);
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
const fetchData = useCallback(async () => {
if (!user || !id) return;
setLoading(true);
try {
const [
{ data: customerData },
{ data: creditsData },
{ data: repaymentsData },
] = await Promise.all([
supabase.from('credit_customers').select('*').eq('id', id).single(),
supabase
.from('credits')
.select('*')
.eq('customer_id', id)
.order('credit_date', { ascending: false }),
supabase
.from('repayments')
.select('*')
.eq('customer_id', id)
.order('repayment_date', { ascending: false }),
]);
if (customerData) {
setCustomer(customerData);
const totalCredits = (creditsData || []).reduce(
(sum, c) => sum + Number(c.amount),
0
);
const totalRepayments = (repaymentsData || []).reduce(
(sum, r) => sum + Number(r.amount),
0
);
setBalance(Math.max(0, totalCredits - totalRepayments));
const combinedActivity: ActivityItem[] = [
...(creditsData || []).map((c) => ({
id: c.id,
type: 'credit' as const,
amount: Number(c.amount),
date: c.credit_date,
note: c.description,
})),
...(repaymentsData || []).map((r) => ({
id: r.id,
type: 'repayment' as const,
amount: Number(r.amount),
date: r.repayment_date,
note: r.note,
})),
].sort((a, b) => b.date.localeCompare(a.date));
setActivity(combinedActivity);
}
} catch (err) {
console.error('Error fetching customer detail:', err);
} finally {
setLoading(false);
}
}, [user, id]);
useIonViewWillEnter(() => {
fetchData();
}, [fetchData]);
const handleRefresh = async (e: CustomEvent) => {
await fetchData();
e.detail.complete();
};
const handleDelete = async () => {
try {
const { error } = await supabase
.from('credit_customers')
.delete()
.eq('id', id);
if (error) throw error;
history.push('/customers');
} catch (err) {
console.error('Error deleting customer:', err);
}
};
const getInitials = (name: string) => {
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2);
};
if (!loading && !customer) {
return (
<IonPage>
<IonContent
className="ion-padding"
style={{ '--background': '#f0f4ff' }}
>
<EmptyState
icon="alert-circle-outline"
title="Customer not found"
actionText="Go Back"
onAction={() => history.push('/customers')}
/>
</IonContent>
</IonPage>
);
}
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': '#f0f4ff' }}>
<IonButtons slot="start">
<IonButton color="dark" onClick={() => history.push('/customers')}>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
<IonTitle className="bold">Customer</IonTitle>
<IonButtons slot="end">
<IonButton color="danger" onClick={() => setShowDeleteAlert(true)}>
<IonIcon icon={trashOutline} slot="icon-only" />
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent fullscreen style={{ '--background': '#f0f4ff' }}>
<IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
<IonRefresherContent />
</IonRefresher>
<div className="page-padding" style={{ paddingTop: '16px' }}>
<div className="widget-card" style={{ padding: '24px 20px' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
marginBottom: '20px',
}}
>
<div
className="icon-container bold"
style={{
width: '56px',
height: '56px',
fontSize: '18px',
marginRight: '16px',
borderRadius: '16px',
}}
>
{customer ? getInitials(customer.name) : ''}
</div>
<div>
<h2 className="bold" style={{ margin: '0', fontSize: '20px' }}>
{customer?.name}
</h2>
{customer?.phone && (
<p className="secondary-text" style={{ margin: '2px 0 0 0' }}>
{customer.phone}
</p>
)}
</div>
</div>
<div style={{ marginBottom: '24px' }}>
<span className="tertiary-text">OUTSTANDING BALANCE</span>
<div
className="tabular"
style={{
fontSize: '34px',
fontWeight: '400',
color:
balance > 0
? 'var(--ion-color-danger)'
: 'var(--ion-color-success)',
marginTop: '4px',
}}
>
{formatMoney(balance, currency)}
</div>
<div
style={{
display: 'inline-block',
padding: '4px 12px',
borderRadius: '999px',
fontSize: '12px',
fontWeight: '600',
marginTop: '8px',
background:
balance > 0
? 'var(--color-danger-soft)'
: 'var(--color-success-soft)',
color:
balance > 0
? 'var(--ion-color-danger)'
: 'var(--ion-color-success)',
}}
>
{balance > 0 ? 'OWING' : 'CLEARED'}
</div>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<IonButton
fill="clear"
className="bold"
style={{
flex: 1,
fontSize: '14px',
background: 'var(--color-surface-secondary)',
color: 'var(--color-text-primary)',
'--border-radius': 'var(--radius-inner)',
height: '44px',
}}
onClick={() => history.push('/credit/new', { customerId: id })}
>
<IonIcon icon={personAddOutline} slot="start" />
Credit
</IonButton>
<IonButton
fill="solid"
className="bold"
style={{
flex: 1,
fontSize: '14px',
'--border-radius': 'var(--radius-inner)',
height: '44px',
}}
onClick={() =>
history.push('/repayment/new', { customerId: id })
}
disabled={balance === 0}
>
<IonIcon icon={cashOutline} slot="start" />
Repay
</IonButton>
</div>
</div>
</div>
<div className="section-header">
<h2>Activity</h2>
</div>
<div className="page-padding" style={{ paddingBottom: '40px' }}>
{loading ? (
<div className="widget-card" style={{ gap: '16px' }}>
{[1, 2, 3].map((i) => (
<div
key={i}
style={{ display: 'flex', gap: '12px', alignItems: 'center' }}
>
<div
style={{
width: '40px',
height: '40px',
borderRadius: '12px',
background: 'var(--color-surface-secondary)',
}}
/>
<div style={{ flex: 1 }}>
<div
style={{
width: '60%',
height: '12px',
background: 'var(--color-surface-secondary)',
borderRadius: '4px',
marginBottom: '6px',
}}
/>
<div
style={{
width: '40%',
height: '10px',
background: 'var(--color-surface-secondary)',
borderRadius: '4px',
}}
/>
</div>
</div>
))}
</div>
) : activity.length > 0 ? (
<div className="widget-card" style={{ padding: '8px 16px' }}>
{activity.map((item) => (
<ActivityRow
key={`${item.type}-${item.id}`}
item={item}
showDate
/>
))}
</div>
) : (
<EmptyState
icon={receiptOutline}
title="No activity yet"
description="Record the first credit or repayment for this customer."
/>
)}
</div>
<IonAlert
isOpen={showDeleteAlert}
onDidDismiss={() => setShowDeleteAlert(false)}
header="Delete Customer"
message="Are you sure you want to delete this customer? All their credit and repayment history will be lost."
buttons={[
{ text: 'Cancel', role: 'cancel' },
{ text: 'Delete', role: 'destructive', handler: handleDelete },
]}
/>
</IonContent>
</IonPage>
);
};
export default CustomerDetailPage;
+268
View File
@@ -0,0 +1,268 @@
import React, { useState, useCallback, useMemo } from 'react';
import {
IonContent,
IonPage,
IonIcon,
IonSegmentButton,
IonSearchbar,
IonRefresher,
IonRefresherContent,
useIonViewWillEnter,
} from '@ionic/react';
import { personAddOutline, peopleOutline } from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { supabase } from '../supabase';
import MetricCard from '../components/MetricCard';
import CustomerRow from '../components/CustomerRow';
import EmptyState from '../components/EmptyState';
interface CustomerWithBalance {
id: string;
name: string;
phone: string | null;
balance: number;
}
const CustomersPage: React.FC = () => {
const { user } = useAuth();
const history = useHistory();
const [loading, setLoading] = useState(true);
const [customers, setCustomers] = useState<CustomerWithBalance[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const fetchData = useCallback(async () => {
if (!user) return;
setLoading(true);
try {
const [
{ data: customersData },
{ data: creditsData },
{ data: repaymentsData },
] = await Promise.all([
supabase
.from('credit_customers')
.select('*')
.eq('user_id', user.id)
.order('name'),
supabase
.from('credits')
.select('customer_id, amount')
.eq('user_id', user.id),
supabase
.from('repayments')
.select('customer_id, amount')
.eq('user_id', user.id),
]);
const customerBalances: CustomerWithBalance[] = (customersData || []).map(
(c) => {
const totalCredits = (creditsData || [])
.filter((cr) => cr.customer_id === c.id)
.reduce((sum, cr) => sum + Number(cr.amount), 0);
const totalRepayments = (repaymentsData || [])
.filter((r) => r.customer_id === c.id)
.reduce((sum, r) => sum + Number(r.amount), 0);
return {
id: c.id,
name: c.name,
phone: c.phone,
balance: Math.max(0, totalCredits - totalRepayments),
};
},
);
// Sort: outstanding balances first, then by name
setCustomers(
customerBalances.sort((a, b) => {
if (a.balance > 0 && b.balance === 0) return -1;
if (a.balance === 0 && b.balance > 0) return 1;
return a.name.localeCompare(b.name);
}),
);
} catch (err) {
console.error('Error fetching customers:', err);
} finally {
setLoading(false);
}
}, [user]);
useIonViewWillEnter(() => {
fetchData();
}, [fetchData]);
const handleRefresh = async (e: CustomEvent) => {
await fetchData();
e.detail.complete();
};
const filteredCustomers = useMemo(() => {
const query = searchQuery.toLowerCase().trim();
if (!query) return customers;
return customers.filter(
(c) =>
c.name.toLowerCase().includes(query) ||
(c.phone && c.phone.toLowerCase().includes(query)),
);
}, [customers, searchQuery]);
const totalOutstanding = useMemo(() => {
return customers.reduce((sum, c) => sum + c.balance, 0);
}, [customers]);
return (
<IonPage>
<IonContent
fullscreen
style={{
'--background':
'linear-gradient(160deg, rgba(199,210,254,0.35) 0%, #f0f4ff 58%, #f8fafc 100%)',
}}
>
<IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
<IonRefresherContent />
</IonRefresher>
<div
style={{
padding: 'calc(16px + var(--ion-safe-area-top)) 20px 16px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div>
<h1 className="bold" style={{ margin: '0', fontSize: '20px' }}>
Credit customers
</h1>
<p className="secondary-text" style={{ margin: '0' }}>
Track who owes your shop
</p>
</div>
<div
className="icon-container"
style={{
borderRadius: '12px',
background: '#ffffff',
color: 'var(--ion-color-primary)',
}}
onClick={() => history.push('/credit/new')}
>
<IonIcon icon={personAddOutline} style={{ fontSize: '22px' }} />
</div>
</div>
<div className="page-padding">
<IonSearchbar
value={searchQuery}
onIonInput={(e) => setSearchQuery(e.detail.value!)}
placeholder="Search customers"
className="custom-searchbar"
style={{
'--background': 'var(--color-surface-secondary)',
'--border-radius': '24px',
'--box-shadow': 'none',
padding: '0',
height: '44px',
marginBottom: '16px',
}}
/>
<MetricCard
label="Total Outstanding"
value={totalOutstanding}
fullWidth
loading={loading}
/>
</div>
<div className="section-header">
<h2>
Customers{' '}
{!loading && (
<span className="tertiary-text" style={{ marginLeft: '4px' }}>
({customers.length})
</span>
)}
</h2>
</div>
<div className="page-padding" style={{ paddingBottom: '120px' }}>
{loading ? (
<div className="widget-card" style={{ gap: '16px' }}>
{[1, 2, 3].map((i) => (
<div
key={i}
style={{ display: 'flex', gap: '12px', alignItems: 'center' }}
>
<div
style={{
width: '44px',
height: '44px',
borderRadius: '12px',
background: 'var(--color-surface-secondary)',
}}
/>
<div style={{ flex: 1 }}>
<div
style={{
width: '50%',
height: '14px',
background: 'var(--color-surface-secondary)',
borderRadius: '4px',
marginBottom: '6px',
}}
/>
<div
style={{
width: '30%',
height: '10px',
background: 'var(--color-surface-secondary)',
borderRadius: '4px',
}}
/>
</div>
</div>
))}
</div>
) : customers.length > 0 ? (
<>
{filteredCustomers.length > 0 ? (
<div className="widget-card" style={{ padding: '8px 16px' }}>
{filteredCustomers.map((c) => (
<CustomerRow
key={c.id}
id={c.id}
name={c.name}
phone={c.phone || undefined}
balance={c.balance}
onClick={() => history.push(`/customers/${c.id}`)}
/>
))}
</div>
) : (
<div style={{ textAlign: 'center', padding: '40px 20px' }}>
<p className="secondary-text">
No customers matching "{searchQuery}"
</p>
</div>
)}
</>
) : (
<EmptyState
icon={peopleOutline}
title="No credit customers yet"
description="Add a credit sale to start tracking balances."
actionText="Add Credit"
onAction={() => history.push('/credit/new')}
/>
)}
</div>
</IonContent>
</IonPage>
);
};
export default CustomersPage;
+162
View File
@@ -0,0 +1,162 @@
import React, { useState } from 'react';
import {
IonContent,
IonPage,
IonInput,
IonButton,
IonSpinner,
IonIcon,
} from '@ionic/react';
import { chevronBackOutline } from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
const ForgotPasswordPage: React.FC = () => {
const [email, setEmail] = useState('');
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [messageType, setMessageType] = useState<'error' | 'success'>('error');
const history = useHistory();
const showMessage = (msg: string, type: 'error' | 'success' = 'error') => {
setMessage(msg);
setMessageType(type);
setTimeout(() => setMessage(null), 4000);
};
const handleReset = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) {
showMessage('Please enter your email');
return;
}
setSubmitting(true);
setMessage(null);
try {
const { error } = await supabase.auth.resetPasswordForEmail(email);
if (error) throw error;
history.push('/verify-email', { email, type: 'recovery' });
} catch (err: any) {
showMessage(err.message || 'An error occurred');
} finally {
setSubmitting(false);
}
};
return (
<IonPage>
<IonContent
fullscreen
style={{
'--background':
'linear-gradient(160deg, rgba(199,210,254,0.52) 0%, #f0f4ff 58%, #f8fafc 100%)',
}}
>
<div className="auth-shell">
<IonButton
fill="clear"
color="dark"
onClick={() => history.push('/auth')}
style={{
position: 'absolute',
top: 'calc(var(--ion-safe-area-top, 0px) + 8px)',
left: '8px',
'--padding-start': '8px',
'--padding-end': '8px',
zIndex: 10,
}}
>
<div
style={{
background: '#ffffff',
width: '44px',
height: '44px',
borderRadius: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IonIcon icon={chevronBackOutline} />
</div>
</IonButton>
<div style={{ marginTop: '80px' }}>
<h1
className="bold"
style={{ fontSize: '24px', margin: '0 0 8px 0' }}
>
Reset password
</h1>
<p
className="secondary-text"
style={{ margin: '0', fontSize: '14px' }}
>
Enter your email and we'll send a 6-digit recovery code.
</p>
</div>
<div className="auth-card">
<form
onSubmit={handleReset}
style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}
>
<div className="input-wrapper">
<IonInput
label="Email"
labelPlacement="floating"
type="email"
placeholder="name@example.com"
value={email}
onIonChange={(e) => setEmail(e.detail.value!)}
/>
</div>
{message && (
<div
style={{
background:
messageType === 'error'
? 'var(--color-danger-soft)'
: 'var(--color-success-soft)',
color:
messageType === 'error'
? 'var(--ion-color-danger-shade)'
: 'var(--ion-color-success-shade)',
padding: '12px',
borderRadius: '12px',
fontSize: '13px',
textAlign: 'center',
}}
>
{message}
</div>
)}
<IonButton
type="submit"
expand="block"
className="bold"
style={{
height: '52px',
'--border-radius': 'var(--radius-pill)',
marginTop: '8px',
}}
disabled={submitting}
>
{submitting ? <IonSpinner name="crescent" /> : 'Send code'}
</IonButton>
</form>
</div>
</div>
</IonContent>
</IonPage>
);
};
export default ForgotPasswordPage;
+339
View File
@@ -0,0 +1,339 @@
import React, { useState, useCallback, useMemo } from 'react';
import {
IonContent,
IonPage,
IonIcon,
IonRefresher,
IonRefresherContent,
IonSegment,
IonSegmentButton,
IonLabel,
IonActionSheet,
useIonViewWillEnter,
} from '@ionic/react';
import {
addOutline,
receiptOutline,
personAddOutline,
cashOutline,
} from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { supabase } from '../supabase';
import ActivityRow, { ActivityItem } from '../components/ActivityRow';
import EmptyState from '../components/EmptyState';
import MetricCard from '../components/MetricCard';
import { groupItemsByDate, formatRelativeDate } from '../utils/dates';
const HistoryPage: React.FC = () => {
const { user } = useAuth();
const history = useHistory();
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<
'all' | 'sales' | 'credit' | 'repayments'
>('all');
const [items, setItems] = useState<ActivityItem[]>([]);
const [showQuickAdd, setShowQuickAdd] = useState(false);
const fetchData = useCallback(async () => {
if (!user) return;
setLoading(true);
try {
const [
{ data: salesData },
{ data: creditsData },
{ data: repaymentsData },
] = await Promise.all([
supabase
.from('sales')
.select('*')
.eq('user_id', user.id)
.order('sale_date', { ascending: false }),
supabase
.from('credits')
.select('*, credit_customers(name)')
.eq('user_id', user.id)
.order('credit_date', { ascending: false }),
supabase
.from('repayments')
.select('*, credit_customers(name)')
.eq('user_id', user.id)
.order('repayment_date', { ascending: false }),
]);
const combined: ActivityItem[] = [
...(salesData || []).map((s) => ({
id: s.id,
type: 'sale' as const,
amount: Number(s.amount),
date: s.sale_date,
note: s.note,
})),
...(creditsData || []).map((c) => ({
id: c.id,
type: 'credit' as const,
amount: Number(c.amount),
date: c.credit_date,
customerName: (c.credit_customers as any)?.name,
note: c.description,
customerId: c.customer_id,
})),
...(repaymentsData || []).map((r) => ({
id: r.id,
type: 'repayment' as const,
amount: Number(r.amount),
date: r.repayment_date,
customerName: (r.credit_customers as any)?.name,
note: r.note,
customerId: r.customer_id,
})),
].sort((a, b) => b.date.localeCompare(a.date));
setItems(combined);
} catch (err) {
console.error('Error fetching history:', err);
} finally {
setLoading(false);
}
}, [user]);
useIonViewWillEnter(() => {
fetchData();
}, [fetchData]);
const handleRefresh = async (e: CustomEvent) => {
await fetchData();
e.detail.complete();
};
const filteredItems = useMemo(() => {
if (filter === 'all') return items;
if (filter === 'sales') return items.filter((i) => i.type === 'sale');
if (filter === 'credit') return items.filter((i) => i.type === 'credit');
if (filter === 'repayments')
return items.filter((i) => i.type === 'repayment');
return items;
}, [items, filter]);
const groupedItems = useMemo(() => {
return groupItemsByDate(filteredItems);
}, [filteredItems]);
const totals = useMemo(() => {
const salesTotal = items
.filter((i) => i.type === 'sale')
.reduce((sum, i) => sum + i.amount, 0);
const repaymentsTotal = items
.filter((i) => i.type === 'repayment')
.reduce((sum, i) => sum + i.amount, 0);
return { salesTotal, repaymentsTotal };
}, [items]);
return (
<IonPage>
<IonContent
fullscreen
style={{
'--background':
'linear-gradient(160deg, rgba(199,210,254,0.35) 0%, #f0f4ff 58%, #f8fafc 100%)',
}}
>
<IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
<IonRefresherContent />
</IonRefresher>
<div
style={{
padding: 'calc(16px + var(--ion-safe-area-top)) 20px 16px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div>
<h1 className="bold" style={{ margin: '0', fontSize: '20px' }}>
History
</h1>
<p className="secondary-text" style={{ margin: '0' }}>
All your transactions
</p>
</div>
<div
className="icon-container"
style={{
borderRadius: '12px',
background: '#ffffff',
color: 'var(--ion-color-primary)',
}}
onClick={() => setShowQuickAdd(true)}
>
<IonIcon icon={addOutline} style={{ fontSize: '22px' }} />
</div>
</div>
<div className="page-padding">
<IonSegment
value={filter}
onIonChange={(e) => setFilter(e.detail.value as any)}
style={{
'--background': 'var(--color-surface-secondary)',
borderRadius: '12px',
padding: '4px',
marginBottom: '16px',
}}
>
<IonSegmentButton
value="all"
style={{
'--indicator-color': 'var(--ion-color-primary)',
color: 'var(--color-text-primary)',
}}
>
<IonLabel className="semibold">All</IonLabel>
</IonSegmentButton>
<IonSegmentButton value="sales">
<IonLabel className="semibold">Sales</IonLabel>
</IonSegmentButton>
<IonSegmentButton value="credit">
<IonLabel className="semibold">Credit</IonLabel>
</IonSegmentButton>
<IonSegmentButton value="repayments">
<IonLabel className="semibold">Paid</IonLabel>
</IonSegmentButton>
</IonSegment>
<div className="metric-pair-row">
<MetricCard
label="Total Sales"
value={totals.salesTotal}
loading={loading}
/>
<MetricCard
label="Total Repaid"
value={totals.repaymentsTotal}
loading={loading}
/>
</div>
</div>
<div
className="page-padding"
style={{ paddingBottom: '120px', marginTop: '8px' }}
>
{loading ? (
<div className="widget-card" style={{ gap: '16px' }}>
{[1, 2, 3].map((i) => (
<div
key={i}
style={{ display: 'flex', gap: '12px', alignItems: 'center' }}
>
<div
style={{
width: '40px',
height: '40px',
borderRadius: '12px',
background: 'var(--color-surface-secondary)',
}}
/>
<div style={{ flex: 1 }}>
<div
style={{
width: '60%',
height: '12px',
background: 'var(--color-surface-secondary)',
borderRadius: '4px',
marginBottom: '6px',
}}
/>
<div
style={{
width: '40%',
height: '10px',
background: 'var(--color-surface-secondary)',
borderRadius: '4px',
}}
/>
</div>
</div>
))}
</div>
) : groupedItems.length > 0 ? (
groupedItems.map((group) => (
<div key={group.date} style={{ marginBottom: '20px' }}>
<div className="tertiary-text" style={{ marginBottom: '12px' }}>
{formatRelativeDate(group.date)}
</div>
<div className="widget-card" style={{ padding: '8px 16px' }}>
{group.items.map((item) => (
<ActivityRow
key={`${item.type}-${item.id}`}
item={item}
onClick={
item.customerId
? () => history.push(`/customers/${item.customerId}`)
: undefined
}
/>
))}
</div>
</div>
))
) : (
<EmptyState
icon={receiptOutline}
title={
filter === 'all' ? 'No activity yet' : `No ${filter} found`
}
description={
filter === 'all'
? 'Start recording sales and credits to see your history.'
: `You haven't recorded any ${filter} yet.`
}
actionText={
filter === 'all' || filter === 'sales'
? 'Add Sale'
: filter === 'credit'
? 'Add Credit'
: 'Record Repayment'
}
onAction={() => {
if (filter === 'sales' || filter === 'all')
history.push('/sale/new');
else if (filter === 'credit') history.push('/credit/new');
else history.push('/repayment/new');
}}
/>
)}
</div>
<IonActionSheet
isOpen={showQuickAdd}
onDidDismiss={() => setShowQuickAdd(false)}
header="Quick Actions"
buttons={[
{
text: 'Add Sale',
icon: receiptOutline,
handler: () => history.push('/sale/new'),
},
{
text: 'Add Credit',
icon: personAddOutline,
handler: () => history.push('/credit/new'),
},
{
text: 'Record Repayment',
icon: cashOutline,
handler: () => history.push('/repayment/new'),
},
{ text: 'Cancel', role: 'cancel' },
]}
/>
</IonContent>
</IonPage>
);
};
export default HistoryPage;
+311 -793
View File
File diff suppressed because it is too large Load Diff
-197
View File
@@ -1,197 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
IonButton,
IonButtons,
IonContent,
IonHeader,
IonIcon,
IonList,
IonPage,
IonRefresher,
IonRefresherContent,
IonText,
IonToolbar,
useIonViewWillEnter,
} from '@ionic/react';
import {
chevronBackOutline,
chevronForwardOutline,
receiptOutline,
} from 'ionicons/icons';
import { RefresherEventDetail } from '@ionic/core';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import type { Tables } from '../database.types';
import { useAuth } from '../context/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
type Order = Tables<'orders'>;
const statusCopy: Record<string, string> = {
confirmed: 'Confirmed',
packed: 'Packed',
out_for_delivery: 'Out for delivery',
delivered: 'Delivered',
};
const MyOrders: React.FC = () => {
const history = useHistory();
const { user } = useAuth();
const [orders, setOrders] = useState<Order[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
useIonViewWillEnter(() => {
void setStatusBarStyle(Style.Dark);
});
const loadOrders = async () => {
if (!user) {
setOrders([]);
setErrorMessage('');
setIsLoading(false);
return;
}
try {
setErrorMessage('');
const { data, error } = await supabase
.from('orders')
.select('*')
.eq('user_id', user.id)
.order('created_at', { ascending: false });
if (error) {
throw error;
}
setOrders(data ?? []);
} catch (error) {
console.error('Failed to load orders', error);
setOrders([]);
setErrorMessage('We could not load your orders right now.');
window.setTimeout(() => setErrorMessage(''), 4000);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (!user) {
setOrders([]);
setErrorMessage('');
setIsLoading(false);
return;
}
setIsLoading(true);
void loadOrders();
}, [user?.id]);
const handleRefresh = async (event: CustomEvent<RefresherEventDetail>) => {
await loadOrders();
event.detail.complete();
};
return (
<IonPage>
<IonHeader className="ion-no-border wallet-header wallet-header-modern">
<IonToolbar>
<div className="wallet-toolbar-inner wallet-toolbar-inner-single-action">
<IonButton
fill="clear"
className="wallet-back-button wallet-back-button-modern"
aria-label="Back to home"
onClick={() => history.replace('/home')}
>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
<div className="wallet-toolbar-title-block">
<span>Tracking</span>
<strong>My Orders</strong>
</div>
<span className="wallet-toolbar-placeholder" />
</div>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
<IonRefresherContent />
</IonRefresher>
{errorMessage && (
<IonText color="danger">
<p>{errorMessage}</p>
</IonText>
)}
{!user ? (
<div className="empty-state cart-empty">
<IonIcon icon={receiptOutline} />
<h3>No tracked orders yet</h3>
<p className="muted-text">
Sign in later to sync orders, or place an order now and track it
from the confirmation screen.
</p>
<IonButton onClick={() => history.push('/home')}>
Continue shopping
</IonButton>
</div>
) : isLoading ? (
<div className="empty-state cart-empty">
<IonIcon icon={receiptOutline} />
<h3>Loading your orders</h3>
<p className="muted-text">
We are fetching your latest order updates.
</p>
</div>
) : orders.length === 0 ? (
<div className="empty-state cart-empty">
<IonIcon icon={receiptOutline} />
<h3>No orders yet</h3>
<p className="muted-text">
Your placed orders will appear here so you can track their status.
</p>
<IonButton onClick={() => history.push('/home')}>
Start shopping
</IonButton>
</div>
) : (
<IonList lines="none" className="orders-list">
{orders.map((order) => (
<button
key={order.id}
type="button"
className="order-card"
onClick={() => history.push(`/orders/${order.id}`)}
>
<div className="order-card-top">
<div>
<span className="order-label">Order</span>
<h3>{order.order_number}</h3>
</div>
<IonIcon icon={chevronForwardOutline} />
</div>
<div className="order-card-row">
<span>Status</span>
<strong>{statusCopy[order.status] ?? order.status}</strong>
</div>
<div className="order-card-row">
<span>Items</span>
<strong>{order.item_count}</strong>
</div>
<div className="order-card-row">
<span>Total</span>
<strong>${Number(order.total).toFixed(2)}</strong>
</div>
<div className="order-card-date">
{new Date(order.created_at).toLocaleString()}
</div>
</button>
))}
</IonList>
)}
</IonContent>
</IonPage>
);
};
export default MyOrders;
+180
View File
@@ -0,0 +1,180 @@
import React, { useState } from 'react';
import {
IonContent,
IonPage,
IonInput,
IonButton,
IonSpinner,
IonIcon,
} from '@ionic/react';
import { chevronBackOutline } from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
const NewPasswordPage: React.FC = () => {
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const history = useHistory();
const showMessage = (msg: string) => {
setMessage(msg);
setTimeout(() => setMessage(null), 4000);
};
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault();
if (!password || !confirmPassword) {
showMessage('Please fill in all fields');
return;
}
if (password.length < 6) {
showMessage('Password must be at least 6 characters');
return;
}
if (password !== confirmPassword) {
showMessage('Passwords do not match');
return;
}
setSubmitting(true);
setMessage(null);
try {
const { error } = await supabase.auth.updateUser({ password });
if (error) throw error;
history.push('/home');
} catch (err: any) {
showMessage(err.message || 'An error occurred');
} finally {
setSubmitting(false);
}
};
return (
<IonPage>
<IonContent
fullscreen
style={{
'--background':
'linear-gradient(160deg, rgba(199,210,254,0.52) 0%, #f0f4ff 58%, #f8fafc 100%)',
}}
>
<div className="auth-shell">
<IonButton
fill="clear"
color="dark"
onClick={() => history.push('/auth')}
style={{
position: 'absolute',
top: 'calc(var(--ion-safe-area-top, 0px) + 8px)',
left: '8px',
'--padding-start': '8px',
'--padding-end': '8px',
zIndex: 10,
}}
>
<div
style={{
background: '#ffffff',
width: '44px',
height: '44px',
borderRadius: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IonIcon icon={chevronBackOutline} />
</div>
</IonButton>
<div style={{ marginTop: '80px' }}>
<h1
className="bold"
style={{ fontSize: '24px', margin: '0 0 8px 0' }}
>
Create new password
</h1>
<p
className="secondary-text"
style={{ margin: '0', fontSize: '14px' }}
>
Set a strong password to protect your account.
</p>
</div>
<div className="auth-card">
<form
onSubmit={handleUpdate}
style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}
>
<div className="input-wrapper">
<IonInput
label="New Password"
labelPlacement="floating"
type="password"
placeholder="Min. 6 characters"
value={password}
onIonChange={(e) => setPassword(e.detail.value!)}
/>
</div>
<div className="input-wrapper">
<IonInput
label="Confirm Password"
labelPlacement="floating"
type="password"
placeholder="Repeat your password"
value={confirmPassword}
onIonChange={(e) => setConfirmPassword(e.detail.value!)}
/>
</div>
{message && (
<div
style={{
background: 'var(--color-danger-soft)',
color: 'var(--ion-color-danger-shade)',
padding: '12px',
borderRadius: '12px',
fontSize: '13px',
textAlign: 'center',
}}
>
{message}
</div>
)}
<IonButton
type="submit"
expand="block"
className="bold"
style={{
height: '52px',
'--border-radius': 'var(--radius-pill)',
marginTop: '8px',
}}
disabled={submitting}
>
{submitting ? (
<IonSpinner name="crescent" />
) : (
'Update password'
)}
</IonButton>
</form>
</div>
</div>
</IonContent>
</IonPage>
);
};
export default NewPasswordPage;
-163
View File
@@ -1,163 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
IonButton,
IonButtons,
IonContent,
IonHeader,
IonIcon,
IonPage,
IonToolbar,
useIonViewWillEnter,
} from '@ionic/react';
import { checkmarkCircle, chevronBackOutline } from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { LAST_ORDER_STORAGE_KEY } from './Home';
import {
setStatusBarBackground,
setStatusBarStyle,
Style,
} from '../utils/statusBar';
type OrderInfo = {
id: string;
orderNumber: string;
total: number;
itemCount: number;
status: string;
customerName: string;
customerPhone: string;
deliveryAddress: string;
deliveryNotes: string;
};
const OrderConfirmation: React.FC = () => {
const history = useHistory();
const [order, setOrder] = useState<OrderInfo | null>(null);
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
setStatusBarBackground('#ffffff');
});
useEffect(() => {
try {
const savedOrder = localStorage.getItem(LAST_ORDER_STORAGE_KEY);
if (!savedOrder) {
setOrder(null);
return;
}
setOrder(JSON.parse(savedOrder) as OrderInfo);
} catch {
setOrder(null);
}
}, []);
return (
<IonPage>
<IonHeader className="ion-no-border wallet-header wallet-header-modern">
<IonToolbar>
<div className="wallet-toolbar-inner wallet-toolbar-inner-single-action">
<IonButton
fill="clear"
className="wallet-back-button wallet-back-button-modern"
aria-label="Back to home"
onClick={() => history.replace('/home')}
>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
<div className="wallet-toolbar-title-block">
<span>Success</span>
<strong>Order placed</strong>
</div>
<span className="wallet-toolbar-placeholder" />
</div>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<div className="confirmation-card">
<IonIcon icon={checkmarkCircle} color="success" />
<h1>Thanks for your order!</h1>
<p className="muted-text">
Your order has been placed successfully and is now being prepared.
</p>
{order ? (
<>
<div className="confirmation-details">
<div>
<span>Order number</span>
<strong>{order.orderNumber}</strong>
</div>
<div>
<span>Status</span>
<strong>{order.status.replaceAll('_', ' ')}</strong>
</div>
<div>
<span>Items</span>
<strong>{order.itemCount}</strong>
</div>
<div>
<span>Total paid</span>
<strong>${order.total.toFixed(2)}</strong>
</div>
</div>
<div className="delivery-details-card">
<h2>Delivery information</h2>
<div className="delivery-detail-row">
<span>Customer</span>
<strong>{order.customerName}</strong>
</div>
<div className="delivery-detail-row">
<span>Phone</span>
<strong>{order.customerPhone}</strong>
</div>
<div className="delivery-detail-row delivery-detail-block">
<span>Address</span>
<strong>{order.deliveryAddress}</strong>
</div>
{order.deliveryNotes && (
<div className="delivery-detail-row delivery-detail-block">
<span>Notes</span>
<strong>{order.deliveryNotes}</strong>
</div>
)}
</div>
</>
) : (
<p className="muted-text">
We could not load the order summary, but your next browse session
is ready.
</p>
)}
{order && (
<IonButton
expand="block"
onClick={() => history.replace(`/orders/${order.id}`)}
>
Track this order
</IonButton>
)}
<IonButton
fill="outline"
expand="block"
onClick={() => history.replace('/orders')}
>
View all orders
</IonButton>
<IonButton
fill="clear"
expand="block"
onClick={() => history.replace('/home')}
>
Continue shopping
</IonButton>
</div>
</IonContent>
</IonPage>
);
};
export default OrderConfirmation;
-246
View File
@@ -1,246 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
IonButton,
IonContent,
IonHeader,
IonIcon,
IonItem,
IonLabel,
IonList,
IonLoading,
IonPage,
IonRefresher,
IonRefresherContent,
IonText,
IonToolbar,
useIonViewWillEnter,
} from '@ionic/react';
import { RefresherEventDetail } from '@ionic/core';
import { chevronBackOutline } from 'ionicons/icons';
import { RouteComponentProps, useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import type { Tables } from '../database.types';
import { useAuth } from '../context/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
type Props = RouteComponentProps<{ id: string }>;
type Order = Tables<'orders'>;
type OrderItem = Tables<'order_items'>;
const statusSteps = [
'confirmed',
'packed',
'out_for_delivery',
'delivered',
] as const;
const statusTitles: Record<(typeof statusSteps)[number], string> = {
confirmed: 'Confirmed',
packed: 'Packed',
out_for_delivery: 'Out for delivery',
delivered: 'Delivered',
};
const OrderDetail: React.FC<Props> = ({ match }) => {
const history = useHistory();
const { user } = useAuth();
const [order, setOrder] = useState<Order | null>(null);
const [items, setItems] = useState<OrderItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
useIonViewWillEnter(() => {
void setStatusBarStyle(Style.Dark);
});
const loadOrder = async () => {
if (!user) {
setIsLoading(false);
return;
}
setIsLoading(true);
const [
{ data: orderData, error: orderError },
{ data: itemData, error: itemError },
] = await Promise.all([
supabase
.from('orders')
.select('*')
.eq('id', match.params.id)
.eq('user_id', user.id)
.single(),
supabase
.from('order_items')
.select('*')
.eq('order_id', match.params.id)
.order('created_at', { ascending: true }),
]);
if (orderError || itemError || !orderData) {
setErrorMessage('We could not load this order.');
window.setTimeout(() => setErrorMessage(''), 4000);
setOrder(null);
setItems([]);
setIsLoading(false);
return;
}
setOrder(orderData);
setItems(itemData ?? []);
setIsLoading(false);
};
useEffect(() => {
void loadOrder();
}, [match.params.id, user?.id]);
const activeStepIndex = useMemo(
() =>
order
? Math.max(
statusSteps.indexOf(order.status as (typeof statusSteps)[number]),
0
)
: 0,
[order]
);
const handleRefresh = async (event: CustomEvent<RefresherEventDetail>) => {
await loadOrder();
event.detail.complete();
};
return (
<IonPage>
<IonHeader className="ion-no-border wallet-header wallet-header-modern">
<IonToolbar>
<div className="wallet-toolbar-inner wallet-toolbar-inner-single-action">
<IonButton
fill="clear"
className="wallet-back-button wallet-back-button-modern"
aria-label="Back to orders"
onClick={() => history.replace('/orders')}
>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
<div className="wallet-toolbar-title-block">
<span>Tracking</span>
<strong>Order details</strong>
</div>
<span className="wallet-toolbar-placeholder" />
</div>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
<IonRefresherContent />
</IonRefresher>
<IonLoading isOpen={isLoading} message="Loading order details..." />
{errorMessage && (
<IonText color="danger">
<p>{errorMessage}</p>
</IonText>
)}
{!user ? (
<div className="empty-state cart-empty">
<h3>Sign in to view order details</h3>
<IonButton onClick={() => history.replace('/auth')}>
Sign in
</IonButton>
</div>
) : !isLoading && !order ? (
<div className="empty-state cart-empty">
<h3>Order not found</h3>
<p className="muted-text">
We could not find that order. Try returning to your order list.
</p>
<IonButton onClick={() => history.replace('/orders')}>
Back to orders
</IonButton>
</div>
) : (
order && (
<>
<div className="tracking-summary-card">
<span className="order-label">Order number</span>
<h1>{order.order_number}</h1>
<p className="muted-text">
Placed on {new Date(order.created_at).toLocaleString()}
</p>
<div className="order-card-row">
<span>Current status</span>
<strong>
{statusTitles[
order.status as (typeof statusSteps)[number]
] ?? order.status}
</strong>
</div>
<div className="order-card-row">
<span>Total</span>
<strong>${Number(order.total).toFixed(2)}</strong>
</div>
</div>
<div className="timeline-card">
<h2>Delivery progress</h2>
<div className="tracking-timeline">
{statusSteps.map((step, index) => (
<div
key={step}
className={`timeline-step ${index <= activeStepIndex ? 'active' : ''}`}
>
<div className="timeline-dot" />
<div>
<strong>{statusTitles[step]}</strong>
<p className="muted-text">
{index < activeStepIndex
? 'Completed'
: index === activeStepIndex
? 'Current step'
: 'Up next'}
</p>
</div>
</div>
))}
</div>
</div>
<div className="timeline-card delivery-details-card">
<h2>Delivery details</h2>
<div className="delivery-detail-row">
<span>Customer</span>
<strong>{order.customer_name || '—'}</strong>
</div>
<div className="delivery-detail-row">
<span>Phone</span>
<strong>{order.customer_phone || '—'}</strong>
</div>
<div className="delivery-detail-row delivery-detail-block">
<span>Address</span>
<strong>{order.delivery_address || '—'}</strong>
</div>
{order.delivery_notes && (
<div className="delivery-detail-row delivery-detail-block">
<span>Notes</span>
<strong>{order.delivery_notes}</strong>
</div>
)}
</div>
<div className="timeline-card">
<h2>Items in this order</h2>
<IonList lines="none" className="cart-list">
{items.map((item) => (
<IonItem key={item.id} className="cart-item">
<IonLabel>
<h2>{item.product_name}</h2>
<p>${Number(item.unit_price).toFixed(2)} each</p>
<strong>
Qty {item.quantity} · $
{(Number(item.unit_price) * item.quantity).toFixed(2)}
</strong>
</IonLabel>
</IonItem>
))}
</IonList>
</div>
</>
)
)}
</IonContent>
</IonPage>
);
};
export default OrderDetail;
-352
View File
@@ -1,352 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
IonButton,
IonButtons,
IonContent,
IonHeader,
IonIcon,
IonLoading,
IonPage,
IonText,
IonToolbar,
useIonViewWillEnter,
} from '@ionic/react';
import {
addOutline,
cardOutline,
checkmarkCircleOutline,
chevronBackOutline,
trashOutline,
} from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../context/AuthContext';
import type { Tables } from '../database.types';
import { setStatusBarStyle, Style } from '../utils/statusBar';
type PaymentMethodRow = Tables<'payment_methods'>;
type DisplayPaymentMethod = Pick<
PaymentMethodRow,
| 'id'
| 'brand'
| 'last4'
| 'expiry_month'
| 'expiry_year'
| 'is_default'
| 'cardholder_name'
>;
const formatExpiry = (month: number, year: number) =>
`${String(month).padStart(2, '0')}/${String(year).slice(-2)}`;
const PaymentMethods: React.FC = () => {
const history = useHistory();
const { user } = useAuth();
const [methods, setMethods] = useState<PaymentMethodRow[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [message, setMessage] = useState('');
const [error, setError] = useState('');
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Light);
});
const showMessage = (text: string) => {
setMessage(text);
window.setTimeout(() => setMessage(''), 4000);
};
const loadMethods = async () => {
if (!user) {
setMethods([]);
setIsLoading(false);
return;
}
setIsLoading(true);
setError('');
const { data, error: loadError } = await supabase
.from('payment_methods')
.select('*')
.eq('user_id', user.id)
.order('is_default', { ascending: false })
.order('created_at', { ascending: false });
if (loadError) {
setError('Unable to load your payment methods.');
}
setMethods(data ?? []);
setIsLoading(false);
};
useEffect(() => {
void loadMethods();
}, [user]);
const setDefaultMethod = async (methodId: string) => {
if (!user) return;
await supabase
.from('payment_methods')
.update({ is_default: false })
.eq('user_id', user.id);
const { error: updateError } = await supabase
.from('payment_methods')
.update({ is_default: true })
.eq('id', methodId)
.eq('user_id', user.id);
if (updateError) {
setError('Could not update your default payment method.');
return;
}
showMessage('Default payment method updated.');
void loadMethods();
};
const renderCardVisual = (method: DisplayPaymentMethod) => (
<div className="payment-method-visual-card">
<div className="wallet-card-top-row">
<div className="wallet-card-chip" />
<span className="wallet-card-brand">{method.brand.toUpperCase()}</span>
</div>
<div className="wallet-card-number"> {method.last4}</div>
<div className="wallet-card-meta-row">
<div>
<span>Cardholder</span>
<strong>{method.cardholder_name || 'Saved Card'}</strong>
</div>
<div className="wallet-card-meta-right">
<span>Valid Thru</span>
<strong>
{formatExpiry(method.expiry_month, method.expiry_year)}
</strong>
</div>
</div>
</div>
);
const removeMethod = async (method: PaymentMethodRow) => {
const { error: deleteError } = await supabase
.from('payment_methods')
.delete()
.eq('id', method.id)
.eq('user_id', method.user_id);
if (deleteError) {
setError('Could not remove that payment method.');
return;
}
if (method.is_default && user) {
const { data: remaining } = await supabase
.from('payment_methods')
.select('id')
.eq('user_id', user.id)
.order('created_at', { ascending: false })
.limit(1);
const nextId = remaining?.[0]?.id;
if (nextId) {
await supabase
.from('payment_methods')
.update({ is_default: true })
.eq('id', nextId)
.eq('user_id', user.id);
}
}
showMessage('Payment method removed.');
void loadMethods();
};
return (
<IonPage>
<IonHeader className="ion-no-border wallet-header wallet-header-modern">
<IonToolbar>
<div className="wallet-toolbar-inner">
<IonButton
fill="clear"
className="wallet-back-button wallet-back-button-modern"
aria-label="Back to wallet"
onClick={() => history.replace('/wallet')}
>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
<div className="wallet-toolbar-title-block">
<span>Wallet</span>
<strong>Payment methods</strong>
</div>
<IonButton
fill="clear"
className="wallet-header-add-button"
aria-label="Add payment method"
onClick={() => history.push('/wallet/payment-methods/add')}
>
<IonIcon icon={addOutline} slot="icon-only" />
</IonButton>
</div>
</IonToolbar>
</IonHeader>
<IonContent
fullscreen
className="wallet-page wallet-page-vibrant payment-methods-page ion-padding"
>
<IonLoading isOpen={isLoading} message="Loading payment methods..." />
{!user ? (
<>
<div className="payment-methods-hero-card">
<span>Cards & checkout</span>
<h1>Payment methods</h1>
<p>
Review saved cards, choose the checkout default, and keep wallet
payments ready for faster ordering.
</p>
</div>
<div className="payment-method-grid">
{[
{
id: 'demo-visa',
brand: 'visa',
last4: '4242',
expiry_month: 8,
expiry_year: 2028,
is_default: true,
cardholder_name: 'Demo User',
},
{
id: 'demo-mastercard',
brand: 'mastercard',
last4: '1881',
expiry_month: 11,
expiry_year: 2027,
is_default: false,
cardholder_name: 'Demo User',
},
].map((method) => (
<div
key={method.id}
className="payment-method-detail-card-modern"
>
{renderCardVisual(method)}
<div className="payment-method-card-footer">
<div>
{method.is_default ? (
<span className="payment-status-pill active">
<IonIcon icon={checkmarkCircleOutline} />
Checkout card
</span>
) : (
<span className="payment-status-pill">Backup card</span>
)}
</div>
<div className="payment-method-modern-actions">
{!method.is_default && (
<IonButton fill="outline" size="small" disabled>
Set default
</IonButton>
)}
<IonButton fill="clear" color="medium" disabled>
<IonIcon icon={trashOutline} slot="icon-only" />
</IonButton>
</div>
</div>
</div>
))}
</div>
</>
) : (
<>
<div className="payment-methods-hero-card">
<span>Cards & checkout</span>
<h1>Payment methods</h1>
<p>
Store masked card details so checkout is quicker next time. You
can choose one card as your checkout default.
</p>
</div>
{message && (
<IonText color="success">
<p className="wallet-inline-message">{message}</p>
</IonText>
)}
{error && (
<IonText color="danger">
<p className="wallet-inline-message">{error}</p>
</IonText>
)}
{methods.length === 0 ? (
<div className="wallet-surface-card wallet-empty-block">
<IonIcon icon={cardOutline} />
<div>
<h3>No payment methods yet</h3>
<p>Add your first card to make repeat ordering faster.</p>
</div>
<IonButton
expand="block"
onClick={() => history.push('/wallet/payment-methods/add')}
>
<IonIcon icon={addOutline} slot="start" />
Add your first card
</IonButton>
</div>
) : (
<div className="payment-method-grid">
{methods.map((method) => (
<div
key={method.id}
className="payment-method-detail-card-modern"
>
{renderCardVisual(method)}
<div className="payment-method-card-footer">
<div>
{method.is_default ? (
<span className="payment-status-pill active">
<IonIcon icon={checkmarkCircleOutline} />
Checkout card
</span>
) : (
<span className="payment-status-pill">
Backup card
</span>
)}
</div>
<div className="payment-method-modern-actions">
{!method.is_default && (
<IonButton
fill="outline"
size="small"
onClick={() => setDefaultMethod(method.id)}
>
Set default
</IonButton>
)}
<IonButton
fill="clear"
color="danger"
aria-label="Remove payment method"
onClick={() => removeMethod(method)}
>
<IonIcon icon={trashOutline} slot="icon-only" />
</IonButton>
</div>
</div>
</div>
))}
</div>
)}
</>
)}
</IonContent>
</IonPage>
);
};
export default PaymentMethods;
-205
View File
@@ -1,205 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
IonButton,
IonButtons,
IonContent,
IonHeader,
IonIcon,
IonPage,
IonText,
IonToolbar,
useIonViewWillEnter,
} from '@ionic/react';
import { cartOutline, chevronBackOutline } from 'ionicons/icons';
import { RouteComponentProps, useHistory } from 'react-router-dom';
import { CartItem, products, readCart, writeCart } from './Home';
import { setStatusBarStyle, Style } from '../utils/statusBar';
type Props = RouteComponentProps<{ id: string }>;
const ProductDetail: React.FC<Props> = ({ match }) => {
const history = useHistory();
const [quantity, setQuantity] = useState(1);
const [message, setMessage] = useState('');
const [cartCount, setCartCount] = useState(0);
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Light);
const count = readCart().reduce((sum, item) => sum + item.quantity, 0);
setCartCount(count);
});
useEffect(() => {
const syncCount = () => {
const count = readCart().reduce((sum, item) => sum + item.quantity, 0);
setCartCount(count);
};
syncCount();
window.addEventListener('storage', syncCount);
window.addEventListener('cart:updated', syncCount as EventListener);
return () => {
window.removeEventListener('storage', syncCount);
window.removeEventListener('cart:updated', syncCount as EventListener);
};
}, []);
const product = useMemo(
() => products.find((item) => item.id === match.params.id),
[match.params.id]
);
const showMessage = (text: string) => {
setMessage(text);
window.setTimeout(() => setMessage(''), 4000);
};
const addToCart = () => {
if (!product) return;
const cart: CartItem[] = readCart();
const existing = cart.find((item) => item.id === product.id);
const nextCart = existing
? cart.map((item) =>
item.id === product.id
? { ...item, quantity: item.quantity + quantity }
: item
)
: [
...cart,
{
id: product.id,
name: product.name,
price: product.price,
quantity,
},
];
writeCart(nextCart);
showMessage('Added to basket');
};
if (!product) {
return (
<IonPage>
<IonHeader className="hybrid-subpage-header ion-no-border">
<IonToolbar className="hybrid-subpage-toolbar">
<IonButtons slot="start">
<IonButton fill="clear" onClick={() => history.replace('/home')}>
<IonIcon icon={chevronBackOutline} />
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding reference-surface">
<div className="empty-state reference-empty-state">
<h3>Product not found</h3>
<p className="muted-text">This product is no longer available.</p>
<IonButton onClick={() => history.replace('/home')}>
Browse products
</IonButton>
</div>
</IonContent>
</IonPage>
);
}
return (
<IonPage>
<IonContent
fullscreen
className="product-detail-page-surface premium-product-surface"
>
<div className="premium-product-shell">
<div className="premium-product-actions">
<button
type="button"
className="premium-back-circle"
aria-label="Back to products"
onClick={() => history.replace('/home')}
>
<IonIcon icon={chevronBackOutline} />
</button>
<button
type="button"
className="premium-cart-tile"
aria-label="Open basket"
onClick={() => history.push('/cart')}
>
<span className="premium-cart-tile-inner">
<IonIcon icon={cartOutline} />
</span>
{cartCount > 0 && (
<span className="premium-cart-count-bubble">{cartCount}</span>
)}
</button>
</div>
<section className="premium-product-intro">
<h1>{product.name}</h1>
<div className="premium-product-meta-row">
<span className="premium-product-badge">{product.category}</span>
<span>High quality</span>
<span>In stock</span>
</div>
</section>
<section className="premium-product-image-panel">
<img src={product.image} alt={product.imageAlt} />
</section>
<section className="premium-product-details-card">
<div className="premium-details-header">
<h2>Details</h2>
</div>
<p>{product.description}</p>
<div className="premium-buy-row">
<div className="premium-price-block">
<strong>${product.price.toFixed(2)}</strong>
<span>each</span>
</div>
<div className="premium-quantity-control" aria-label="Quantity">
<IonButton
fill="clear"
aria-label="Decrease quantity"
onClick={() => setQuantity((value) => Math.max(1, value - 1))}
>
<span aria-hidden="true"></span>
</IonButton>
<strong>{quantity}</strong>
<IonButton
fill="clear"
aria-label="Increase quantity"
onClick={() => setQuantity((value) => value + 1)}
>
<span aria-hidden="true">+</span>
</IonButton>
</div>
</div>
</section>
{message && (
<IonText color="success" className="premium-product-message">
<p>{message}</p>
</IonText>
)}
</div>
<div className="premium-product-cta">
<div>
<span>Total</span>
<strong>${(product.price * quantity).toFixed(2)}</strong>
</div>
<IonButton onClick={addToCart}>Add to basket</IonButton>
</div>
</IonContent>
</IonPage>
);
};
export default ProductDetail;
-147
View File
@@ -1,147 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
IonButton,
IonContent,
IonHeader,
IonIcon,
IonInput,
IonItem,
IonLabel,
IonPage,
IonText,
IonToolbar,
useIonViewWillEnter,
} from '@ionic/react';
import { chevronBackOutline } from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../context/AuthContext';
import { setStatusBarStyle, Style } from '../utils/statusBar';
const Profile: React.FC = () => {
const history = useHistory();
const { user, profile, refreshProfile, signOut } = useAuth();
const [fullName, setFullName] = useState('');
const [phone, setPhone] = useState('');
const [message, setMessage] = useState('');
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
});
useEffect(() => {
setFullName(profile?.full_name ?? '');
setPhone(profile?.phone ?? '');
}, [profile]);
const showMessage = (msg: string) => {
setMessage(msg);
window.setTimeout(() => setMessage(''), 4000);
};
const saveProfile = async () => {
if (!user) return;
const { error } = await supabase.from('profiles').upsert({
id: user.id,
full_name: fullName.trim() || null,
phone: phone.trim() || null,
updated_at: new Date().toISOString(),
});
if (!error) {
await refreshProfile();
showMessage('Profile saved.');
}
};
return (
<IonPage>
<IonHeader className="ion-no-border wallet-header wallet-header-modern">
<IonToolbar>
<div className="wallet-toolbar-inner wallet-toolbar-inner-single-action">
<IonButton
fill="clear"
className="wallet-back-button wallet-back-button-modern"
aria-label="Back to home"
onClick={() => history.replace('/home')}
>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
<div className="wallet-toolbar-title-block">
<span>Account</span>
<strong>Profile</strong>
</div>
<span className="wallet-toolbar-placeholder" />
</div>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<div className="auth-card">
<h1>Your account</h1>
<p className="muted-text">Signed in as {user?.email}</p>
{message && (
<IonText color="success">
<p>{message}</p>
</IonText>
)}
<div className="auth-form">
<IonItem lines="none" className="auth-input-item">
<IonLabel position="stacked">Full name</IonLabel>
<IonInput
value={fullName}
onIonInput={(e) => setFullName(e.detail.value ?? '')}
/>
</IonItem>
<IonItem lines="none" className="auth-input-item">
<IonLabel position="stacked">Phone</IonLabel>
<IonInput
value={phone}
type="tel"
onIonInput={(e) => setPhone(e.detail.value ?? '')}
/>
</IonItem>
<IonButton expand="block" onClick={saveProfile}>
Save profile
</IonButton>
<IonButton
expand="block"
fill="outline"
onClick={() => history.push('/addresses')}
>
Saved addresses
</IonButton>
<IonButton
expand="block"
fill="outline"
onClick={() => history.push('/wallet')}
>
Wallet & payment methods
</IonButton>
{profile?.is_admin && (
<IonButton
expand="block"
fill="outline"
onClick={() => history.push('/admin/orders')}
>
Admin workflow
</IonButton>
)}
<IonButton
color="medium"
expand="block"
fill="clear"
onClick={async () => {
await signOut();
history.replace('/auth');
}}
>
Sign out
</IonButton>
</div>
</div>
</IonContent>
</IonPage>
);
};
export default Profile;
+353
View File
@@ -0,0 +1,353 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
IonContent,
IonPage,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonButton,
IonIcon,
IonInput,
IonTextarea,
IonSpinner,
IonLabel,
IonSelect,
IonSelectOption,
} from '@ionic/react';
import { chevronBackOutline } from 'ionicons/icons';
import { useHistory, useLocation } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { getTodayISO } from '../utils/dates';
import MoneyInput from '../components/MoneyInput';
import { parseMoney, formatMoney } from '../utils/money';
import EmptyState from '../components/EmptyState';
interface CustomerWithBalance {
id: string;
name: string;
balance: number;
}
const RepaymentFormPage: React.FC = () => {
const history = useHistory();
const location = useLocation<{ customerId?: string }>();
const { user, profile } = useAuth();
const currency = profile?.currency || 'USD';
const [customers, setCustomers] = useState<CustomerWithBalance[]>([]);
const [selectedCustomerId, setSelectedCustomerId] = useState<string>(
location.state?.customerId || ''
);
const [amount, setAmount] = useState('');
const [repaymentDate, setRepaymentDate] = useState(getTodayISO());
const [note, setNote] = useState('');
const [submitting, setSubmitting] = useState(false);
const [loadingCustomers, setLoadingCustomers] = useState(true);
const [errors, setErrors] = useState<Record<string, string>>({});
const [message, setMessage] = useState<string | null>(null);
useEffect(() => {
const fetchCustomersAndBalances = async () => {
if (!user) return;
try {
const [
{ data: customersData },
{ data: creditsData },
{ data: repaymentsData },
] = await Promise.all([
supabase
.from('credit_customers')
.select('id, name')
.eq('user_id', user.id),
supabase
.from('credits')
.select('customer_id, amount')
.eq('user_id', user.id),
supabase
.from('repayments')
.select('customer_id, amount')
.eq('user_id', user.id),
]);
const customerBalances: CustomerWithBalance[] = (
customersData || []
).map((c) => {
const totalCredits = (creditsData || [])
.filter((cr) => cr.customer_id === c.id)
.reduce((sum, cr) => sum + Number(cr.amount), 0);
const totalRepayments = (repaymentsData || [])
.filter((r) => r.customer_id === c.id)
.reduce((sum, r) => sum + Number(r.amount), 0);
return {
id: c.id,
name: c.name,
balance: Math.max(0, totalCredits - totalRepayments),
};
});
// Filter for customers with an outstanding balance
const activeCustomers = customerBalances.filter((c) => c.balance > 0);
setCustomers(activeCustomers);
// If a customer was passed in but has no balance, we should handle it
if (
selectedCustomerId &&
!activeCustomers.find((c) => c.id === selectedCustomerId)
) {
setSelectedCustomerId('');
}
} catch (err) {
console.error('Error fetching customers for repayment:', err);
} finally {
setLoadingCustomers(false);
}
};
fetchCustomersAndBalances();
}, [user, selectedCustomerId]);
const selectedCustomer = useMemo(
() => customers.find((c) => c.id === selectedCustomerId),
[customers, selectedCustomerId]
);
const validate = () => {
const newErrors: Record<string, string> = {};
const parsedAmount = parseMoney(amount);
if (!selectedCustomerId) {
newErrors.customer = 'Please select a customer';
} else if (selectedCustomer && parsedAmount > selectedCustomer.balance) {
newErrors.amount = `Amount cannot exceed outstanding balance of ${formatMoney(
selectedCustomer.balance,
currency
)}`;
}
if (parsedAmount <= 0) {
newErrors.amount = 'Amount must be greater than 0';
}
if (!repaymentDate) {
newErrors.date = 'Date is required';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSave = async () => {
if (!validate() || !user) return;
setSubmitting(true);
setMessage(null);
try {
const { error } = await supabase.from('repayments').insert([
{
user_id: user.id,
customer_id: selectedCustomerId,
amount: parseMoney(amount),
repayment_date: repaymentDate,
note: note.trim() || null,
},
]);
if (error) throw error;
history.goBack();
} catch (err: any) {
console.error('Error saving repayment:', err);
setMessage(err.message || 'Could not save repayment');
setTimeout(() => setMessage(null), 4000);
} finally {
setSubmitting(false);
}
};
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': '#f0f4ff' }}>
<IonButtons slot="start">
<IonButton color="dark" onClick={() => history.goBack()}>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
<IonTitle className="bold">Record repayment</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding" style={{ '--background': '#f0f4ff' }}>
{!loadingCustomers && customers.length === 0 ? (
<EmptyState
icon="wallet-outline"
title="No outstanding credit"
description="You don't have any customers with an outstanding balance right now."
actionText="Add Credit"
onAction={() => history.push('/credit/new')}
/>
) : (
<div className="form-card">
<div className="form-field">
<IonLabel
className="semibold"
style={{
marginBottom: '8px',
display: 'block',
fontSize: '13px',
}}
>
Select Customer
</IonLabel>
<div className="input-wrapper">
<IonSelect
interface="action-sheet"
placeholder="Choose a customer"
value={selectedCustomerId}
onIonChange={(e) => setSelectedCustomerId(e.detail.value)}
style={{ width: '100%' }}
disabled={loadingCustomers}
>
{customers.map((c) => (
<IonSelectOption key={c.id} value={c.id}>
{c.name} ({formatMoney(c.balance, currency)})
</IonSelectOption>
))}
</IonSelect>
</div>
{errors.customer && (
<IonLabel
color="danger"
style={{ fontSize: '12px', marginTop: '4px' }}
>
{errors.customer}
</IonLabel>
)}
</div>
{selectedCustomer && (
<div
style={{
background: 'rgba(99,102,241,0.10)',
borderRadius: '12px',
padding: '16px',
display: 'flex',
flexDirection: 'column',
gap: '4px',
}}
>
<span className="tertiary-text" style={{ fontSize: '10px' }}>
OUTSTANDING BALANCE
</span>
<span
className="bold tabular"
style={{
fontSize: '22px',
color: 'var(--ion-color-primary)',
}}
>
{formatMoney(selectedCustomer.balance, currency)}
</span>
</div>
)}
<MoneyInput
label="Repayment amount"
value={amount}
onValueChange={(val) => {
setAmount(val);
if (errors.amount) setErrors({ ...errors, amount: '' });
}}
error={errors.amount}
/>
<div className="form-field">
<IonLabel
className="semibold"
style={{
marginBottom: '8px',
display: 'block',
fontSize: '13px',
}}
>
Repayment date
</IonLabel>
<div className="input-wrapper">
<IonInput
type="date"
value={repaymentDate}
onIonChange={(e) => setRepaymentDate(e.detail.value!)}
/>
</div>
{errors.date && (
<IonLabel
color="danger"
style={{ fontSize: '12px', marginTop: '4px' }}
>
{errors.date}
</IonLabel>
)}
</div>
<div className="form-field">
<IonLabel
className="semibold"
style={{
marginBottom: '8px',
display: 'block',
fontSize: '13px',
}}
>
Note (optional)
</IonLabel>
<div
className="input-wrapper"
style={{ minHeight: '96px', padding: '8px 16px' }}
>
<IonTextarea
placeholder="e.g. Paid in full"
value={note}
onIonChange={(e) => setNote(e.detail.value!)}
rows={4}
/>
</div>
</div>
{message && (
<div
style={{
background: 'var(--color-danger-soft)',
color: 'var(--ion-color-danger-shade)',
padding: '12px',
borderRadius: '12px',
fontSize: '13px',
}}
>
{message}
</div>
)}
<IonButton
expand="block"
className="bold"
style={{
height: '52px',
'--border-radius': 'var(--radius-pill)',
marginTop: '16px',
}}
disabled={submitting || loadingCustomers}
onClick={handleSave}
>
{submitting ? <IonSpinner name="crescent" /> : 'Save repayment'}
</IonButton>
</div>
)}
</IonContent>
</IonPage>
);
};
export default RepaymentFormPage;
+188
View File
@@ -0,0 +1,188 @@
import React, { useState } from 'react';
import {
IonContent,
IonPage,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonButton,
IonIcon,
IonInput,
IonTextarea,
IonSpinner,
IonLabel,
} from '@ionic/react';
import { chevronBackOutline } from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import { getTodayISO } from '../utils/dates';
import MoneyInput from '../components/MoneyInput';
import { parseMoney } from '../utils/money';
const SaleFormPage: React.FC = () => {
const history = useHistory();
const { user } = useAuth();
const [amount, setAmount] = useState('');
const [saleDate, setSaleDate] = useState(getTodayISO());
const [note, setNote] = useState('');
const [submitting, setSubmitting] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [message, setMessage] = useState<string | null>(null);
const validate = () => {
const newErrors: Record<string, string> = {};
const parsedAmount = parseMoney(amount);
if (parsedAmount <= 0) {
newErrors.amount = 'Amount must be greater than 0';
}
if (!saleDate) {
newErrors.date = 'Date is required';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSave = async () => {
if (!validate() || !user) return;
setSubmitting(true);
setMessage(null);
try {
const { error } = await supabase.from('sales').insert([
{
user_id: user.id,
amount: parseMoney(amount),
sale_date: saleDate,
note: note.trim() || null,
},
]);
if (error) throw error;
history.goBack();
} catch (err: any) {
console.error('Error saving sale:', err);
setMessage(err.message || 'Could not save sale');
setTimeout(() => setMessage(null), 4000);
} finally {
setSubmitting(false);
}
};
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar style={{ '--background': '#f0f4ff' }}>
<IonButtons slot="start">
<IonButton color="dark" onClick={() => history.goBack()}>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
<IonTitle className="bold">Add sale</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding" style={{ '--background': '#f0f4ff' }}>
<div className="form-card">
<MoneyInput
label="Sale amount"
value={amount}
onValueChange={(val) => {
setAmount(val);
if (errors.amount) setErrors({ ...errors, amount: '' });
}}
error={errors.amount}
/>
<div className="form-field">
<IonLabel
className="semibold"
style={{
marginBottom: '8px',
display: 'block',
fontSize: '13px',
}}
>
Sale date
</IonLabel>
<div className="input-wrapper">
<IonInput
type="date"
value={saleDate}
onIonChange={(e) => setSaleDate(e.detail.value!)}
/>
</div>
{errors.date && (
<IonLabel
color="danger"
style={{ fontSize: '12px', marginTop: '4px' }}
>
{errors.date}
</IonLabel>
)}
</div>
<div className="form-field">
<IonLabel
className="semibold"
style={{
marginBottom: '8px',
display: 'block',
fontSize: '13px',
}}
>
Note (optional)
</IonLabel>
<div
className="input-wrapper"
style={{ minHeight: '96px', padding: '8px 16px' }}
>
<IonTextarea
placeholder="e.g. Morning rush items"
value={note}
onIonChange={(e) => setNote(e.detail.value!)}
rows={4}
/>
</div>
</div>
{message && (
<div
style={{
background: 'var(--color-danger-soft)',
color: 'var(--ion-color-danger-shade)',
padding: '12px',
borderRadius: '12px',
fontSize: '13px',
}}
>
{message}
</div>
)}
<IonButton
expand="block"
className="bold"
style={{
height: '52px',
'--border-radius': 'var(--radius-pill)',
marginTop: '16px',
}}
disabled={submitting}
onClick={handleSave}
>
{submitting ? <IonSpinner name="crescent" /> : 'Save sale'}
</IonButton>
</div>
</IonContent>
</IonPage>
);
};
export default SaleFormPage;
+275
View File
@@ -0,0 +1,275 @@
import React, { useState, useRef, useEffect } from 'react';
import {
IonContent,
IonPage,
IonInput,
IonButton,
IonSpinner,
IonIcon,
IonText,
} from '@ionic/react';
import { chevronBackOutline } from 'ionicons/icons';
import { useHistory, useLocation } from 'react-router-dom';
import { supabase } from '../supabase';
interface LocationState {
email: string;
type?: 'signup' | 'recovery';
resent?: boolean;
}
const VerifyEmailPage: React.FC = () => {
const history = useHistory();
const location = useLocation<LocationState>();
const email = location.state?.email || '';
const type = location.state?.type || 'signup';
const [digits, setDigits] = useState<string[]>(['', '', '', '', '', '']);
const [submitting, setSubmitting] = useState(false);
const [resending, setResending] = useState(false);
const [cooldown, setCooldown] = useState(location.state?.resent ? 60 : 0);
const [message, setMessage] = useState<string | null>(null);
const [messageType, setMessageType] = useState<'error' | 'success'>('error');
const inputRefs = useRef<HTMLIonInputElement[]>([]);
useEffect(() => {
if (cooldown > 0) {
const timer = setInterval(() => {
setCooldown((c) => c - 1);
}, 1000);
return () => clearInterval(timer);
}
}, [cooldown]);
const showMessage = (msg: string, type: 'error' | 'success' = 'error') => {
setMessage(msg);
setMessageType(type);
setTimeout(() => setMessage(null), 4000);
};
const handleDigitChange = (index: number, value: string) => {
const newDigits = [...digits];
// Only take the last character (for overwriting)
const val = value.slice(-1);
if (val && !/^\d+$/.test(val)) return;
newDigits[index] = val;
setDigits(newDigits);
if (val && index < 5) {
inputRefs.current[index + 1]?.setFocus();
}
};
const handleKeyDown = (index: number, e: any) => {
if (e.key === 'Backspace' && !digits[index] && index > 0) {
inputRefs.current[index - 1]?.setFocus();
}
};
const handleVerify = async () => {
const code = digits.join('');
if (code.length < 6) {
showMessage('Please enter the 6-digit code');
return;
}
setSubmitting(true);
setMessage(null);
try {
const { data, error } = await supabase.auth.verifyOtp({
email,
token: code,
type: (type === 'recovery' ? 'recovery' : 'signup') as any,
});
if (error) throw error;
if (type === 'recovery') {
history.push('/new-password');
} else {
history.push('/home');
}
} catch (err: any) {
showMessage(err.message || 'Invalid or expired code');
} finally {
setSubmitting(false);
}
};
const handleResend = async () => {
if (cooldown > 0) return;
setResending(true);
try {
const { error } = await supabase.auth.resend({
type: (type === 'recovery' ? 'recovery' : 'signup') as any,
email,
});
if (error) throw error;
showMessage('A new code has been sent', 'success');
setCooldown(60);
} catch (err: any) {
showMessage(err.message || 'Failed to resend code');
} finally {
setResending(false);
}
};
return (
<IonPage>
<IonContent
fullscreen
style={{
'--background':
'linear-gradient(160deg, rgba(199,210,254,0.52) 0%, #f0f4ff 58%, #f8fafc 100%)',
}}
>
<div className="auth-shell">
<IonButton
fill="clear"
color="dark"
onClick={() => history.push('/auth')}
style={{
position: 'absolute',
top: 'calc(var(--ion-safe-area-top, 0px) + 8px)',
left: '8px',
'--padding-start': '8px',
'--padding-end': '8px',
zIndex: 10,
}}
>
<div
style={{
background: '#ffffff',
width: '44px',
height: '44px',
borderRadius: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IonIcon icon={chevronBackOutline} />
</div>
</IonButton>
<div style={{ marginTop: '80px' }}>
<h1
className="bold"
style={{ fontSize: '24px', margin: '0 0 8px 0' }}
>
Verify your email
</h1>
<p
className="secondary-text"
style={{ margin: '0', fontSize: '14px' }}
>
We've sent a 6-digit code to <span className="bold">{email}</span>
</p>
</div>
<div className="auth-card">
<div
style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}
>
{digits.map((digit, i) => (
<div
key={i}
style={{
width: '44px',
height: '52px',
background: '#e8edf8',
borderRadius: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IonInput
ref={(el) => {
inputRefs.current[i] = el!;
}}
value={digit}
onIonInput={(e) => handleDigitChange(i, e.detail.value!)}
onKeyDown={(e) => handleKeyDown(i, e)}
type="text"
inputMode="numeric"
style={{
textAlign: 'center',
fontSize: '22px',
fontWeight: '700',
color: '#111827',
}}
/>
</div>
))}
</div>
{message && (
<div
style={{
background:
messageType === 'error'
? 'var(--color-danger-soft)'
: 'var(--color-success-soft)',
color:
messageType === 'error'
? 'var(--ion-color-danger-shade)'
: 'var(--ion-color-success-shade)',
padding: '12px',
borderRadius: '12px',
fontSize: '13px',
textAlign: 'center',
}}
>
{message}
</div>
)}
<IonButton
expand="block"
className="bold"
style={{
height: '52px',
'--border-radius': 'var(--radius-pill)',
marginTop: '16px',
}}
disabled={submitting}
onClick={handleVerify}
>
{submitting ? <IonSpinner name="crescent" /> : 'Verify code'}
</IonButton>
<div style={{ textAlign: 'center', marginTop: '8px' }}>
<IonText
color={cooldown > 0 ? 'medium' : 'primary'}
className="semibold"
style={{
fontSize: '13px',
cursor: cooldown > 0 ? 'default' : 'pointer',
}}
onClick={handleResend}
>
{resending ? (
<IonSpinner name="dots" />
) : cooldown > 0 ? (
`Resend in ${cooldown}s`
) : (
'Resend code'
)}
</IonText>
</div>
</div>
</div>
</IonContent>
</IonPage>
);
};
export default VerifyEmailPage;
-458
View File
@@ -1,458 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
IonButton,
IonContent,
IonHeader,
IonIcon,
IonLoading,
IonPage,
IonText,
IonToolbar,
useIonViewWillEnter,
} from '@ionic/react';
import {
addOutline,
arrowDownCircleOutline,
cardOutline,
chevronBackOutline,
chevronForwardOutline,
walletOutline,
} from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../context/AuthContext';
import type { Tables } from '../database.types';
import { setStatusBarStyle, Style } from '../utils/statusBar';
type WalletRow = Tables<'wallets'>;
type PaymentMethodRow = Tables<'payment_methods'>;
type WalletTransactionRow = Tables<'wallet_transactions'>;
const formatMoney = (amount: number, currency: string) =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
minimumFractionDigits: 2,
}).format(amount);
const Wallet: React.FC = () => {
const history = useHistory();
const { user } = useAuth();
const [wallet, setWallet] = useState<WalletRow | null>(null);
const [methods, setMethods] = useState<PaymentMethodRow[]>([]);
const [transactions, setTransactions] = useState<WalletTransactionRow[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Light);
});
useEffect(() => {
const loadWallet = async () => {
if (!user) {
setWallet(null);
setMethods([]);
setTransactions([]);
setIsLoading(false);
return;
}
setIsLoading(true);
setError('');
const { data: walletData, error: walletError } = await supabase
.from('wallets')
.select('*')
.eq('user_id', user.id)
.maybeSingle();
if (walletError) {
setError('Unable to load your wallet right now.');
setIsLoading(false);
return;
}
setWallet(walletData ?? null);
const [
{ data: methodData, error: methodError },
{ data: txData, error: txError },
] = await Promise.all([
supabase
.from('payment_methods')
.select('*')
.eq('user_id', user.id)
.order('is_default', { ascending: false })
.order('created_at', { ascending: false })
.limit(3),
walletData
? supabase
.from('wallet_transactions')
.select('*')
.eq('wallet_id', walletData.id)
.order('created_at', { ascending: false })
.limit(5)
: Promise.resolve({ data: [], error: null }),
]);
if (methodError || txError) {
setError('Some wallet details could not be loaded.');
}
setMethods(methodData ?? []);
setTransactions(txData ?? []);
setIsLoading(false);
};
void loadWallet();
}, [user]);
const balanceLabel = useMemo(() => {
if (!wallet) return '$0.00';
return formatMoney(wallet.balance, wallet.currency);
}, [wallet]);
return (
<IonPage>
<IonHeader className="ion-no-border wallet-header wallet-header-modern">
<IonToolbar>
<div className="wallet-toolbar-inner">
<IonButton
fill="clear"
className="wallet-back-button wallet-back-button-modern"
aria-label="Back to profile"
onClick={() => history.replace('/profile')}
>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
<div className="wallet-toolbar-title-block">
<span>Payments</span>
<strong>Wallet</strong>
</div>
<IonButton
fill="clear"
className="wallet-header-add-button"
aria-label="Add payment method"
onClick={() => history.push('/wallet/payment-methods/add')}
>
<IonIcon icon={addOutline} slot="icon-only" />
</IonButton>
</div>
</IonToolbar>
</IonHeader>
<IonContent
fullscreen
className="wallet-page wallet-page-vibrant ion-padding"
>
<IonLoading isOpen={isLoading} message="Loading wallet..." />
{!user ? (
<>
<div className="wallet-balance-card wallet-balance-card-vibrant">
<div>
<span className="wallet-balance-label">Available balance</span>
<h1>$48.50</h1>
<p>Refund credits ready for your next basket.</p>
<div className="wallet-balance-actions">
<span>Refund wallet</span>
<span>Instant checkout</span>
</div>
</div>
<div className="wallet-balance-icon">
<IonIcon icon={walletOutline} />
</div>
</div>
<div className="wallet-insight-strip">
<div>
<span>Refunds</span>
<strong>Instant</strong>
</div>
<div>
<span>Checkout</span>
<strong>Faster</strong>
</div>
<div>
<span>Security</span>
<strong>Masked</strong>
</div>
</div>
<div className="wallet-section-heading">
<h2>Saved payment methods</h2>
<IonButton
fill="clear"
onClick={() => history.push('/wallet/payment-methods')}
>
See all
</IonButton>
</div>
<div className="wallet-method-stack">
{[
{
id: 'demo-visa',
brand: 'visa',
last4: '4242',
expiry_month: 8,
expiry_year: 2028,
is_default: true,
cardholder_name: 'Demo User',
},
].map((method) => (
<button
key={method.id}
type="button"
className="wallet-method-card wallet-method-preview-card"
onClick={() => history.push('/wallet/payment-methods')}
>
<div className="wallet-card-visual wallet-card-visual-demo">
<div className="wallet-card-top-row">
<div className="wallet-card-chip" />
<span className="wallet-card-brand">
{method.brand.toUpperCase()}
</span>
</div>
<div className="wallet-card-number">
{method.last4}
</div>
<div className="wallet-card-meta-row">
<div>
<span>Cardholder</span>
<strong>{method.cardholder_name}</strong>
</div>
<div className="wallet-card-meta-right">
<span>Valid Thru</span>
<strong>
{String(method.expiry_month).padStart(2, '0')}/
{String(method.expiry_year).slice(-2)}
</strong>
</div>
</div>
</div>
<div className="wallet-method-preview-footer wallet-method-preview-footer-end">
<IonIcon icon={chevronForwardOutline} />
</div>
</button>
))}
</div>
<div className="wallet-section-heading wallet-section-spacing">
<h2>Recent wallet activity</h2>
</div>
<div className="wallet-activity-list">
{[
{
id: 'txn-1',
title: 'Refund from cancelled order',
description: 'Order QC-481920 refund credited to wallet',
amount: 18.5,
created_at: new Date().toISOString(),
},
{
id: 'txn-2',
title: 'Promotional wallet credit',
description: 'Welcome bonus added to your balance',
amount: 30,
created_at: new Date(Date.now() - 86400000).toISOString(),
},
].map((transaction) => (
<div key={transaction.id} className="wallet-activity-card">
<div>
<strong>{transaction.title}</strong>
<p>{transaction.description}</p>
</div>
<div className="wallet-activity-right">
<span className="wallet-positive">
{formatMoney(transaction.amount, 'USD')}
</span>
<small>
{new Date(transaction.created_at).toLocaleDateString()}
</small>
</div>
</div>
))}
</div>
<div className="wallet-refund-card">
<h3>How refunds work</h3>
<p>
In demo mode, this wallet preview shows how refund credits and
saved payment methods will appear for signed-in users.
</p>
</div>
</>
) : (
<>
<div className="wallet-balance-card wallet-balance-card-vibrant">
<div>
<span className="wallet-balance-label">Available balance</span>
<h1>{balanceLabel}</h1>
<p>Refunds and credits ready for your next basket.</p>
<div className="wallet-balance-actions">
<span>Refund wallet</span>
<span>Instant checkout</span>
</div>
</div>
<div className="wallet-balance-icon">
<IonIcon icon={walletOutline} />
</div>
</div>
{error && (
<IonText color="danger">
<p className="wallet-inline-message">{error}</p>
</IonText>
)}
<div className="wallet-insight-strip">
<div>
<span>Refunds</span>
<strong>Instant</strong>
</div>
<div>
<span>Checkout</span>
<strong>Faster</strong>
</div>
<div>
<span>Security</span>
<strong>Masked</strong>
</div>
</div>
<div className="wallet-section-heading">
<h2>Saved payment methods</h2>
<IonButton
fill="clear"
onClick={() => history.push('/wallet/payment-methods')}
>
See all
</IonButton>
</div>
{methods.length === 0 ? (
<div className="wallet-surface-card wallet-empty-block">
<IonIcon icon={cardOutline} />
<div>
<h3>No saved payment methods</h3>
<p>Add a card once so future checkouts feel faster.</p>
</div>
<IonButton
expand="block"
onClick={() => history.push('/wallet/payment-methods/add')}
>
<IonIcon icon={addOutline} slot="start" />
Add payment method
</IonButton>
</div>
) : (
<div className="wallet-method-stack">
{(() => {
const method =
methods.find((entry) => entry.is_default) ?? methods[0];
if (!method) return null;
return (
<button
key={method.id}
type="button"
className="wallet-method-card wallet-method-preview-card"
onClick={() => history.push('/wallet/payment-methods')}
>
<div className="wallet-card-visual">
<div className="wallet-card-top-row">
<div className="wallet-card-chip" />
<span className="wallet-card-brand">
{method.brand.toUpperCase()}
</span>
</div>
<div className="wallet-card-number">
{method.last4}
</div>
<div className="wallet-card-meta-row">
<div>
<span>Cardholder</span>
<strong>
{method.cardholder_name || 'Saved Card'}
</strong>
</div>
<div className="wallet-card-meta-right">
<span>Valid Thru</span>
<strong>
{String(method.expiry_month).padStart(2, '0')}/
{String(method.expiry_year).slice(-2)}
</strong>
</div>
</div>
</div>
<div className="wallet-method-preview-footer wallet-method-preview-footer-end">
<IonIcon icon={chevronForwardOutline} />
</div>
</button>
);
})()}
</div>
)}
<div className="wallet-section-heading wallet-section-spacing">
<h2>Recent wallet activity</h2>
</div>
{transactions.length === 0 ? (
<div className="wallet-surface-card wallet-empty-block compact-empty-block">
<IonIcon icon={arrowDownCircleOutline} />
<div>
<h3>No wallet activity yet</h3>
<p>
Refund credits and manual wallet adjustments will appear
here.
</p>
</div>
</div>
) : (
<div className="wallet-activity-list">
{transactions.map((transaction) => (
<div key={transaction.id} className="wallet-activity-card">
<div>
<strong>{transaction.title}</strong>
<p>{transaction.description || 'Wallet transaction'}</p>
</div>
<div className="wallet-activity-right">
<span
className={
transaction.amount >= 0
? 'wallet-positive'
: 'wallet-negative'
}
>
{formatMoney(
transaction.amount,
wallet?.currency || 'USD'
)}
</span>
<small>
{new Date(transaction.created_at).toLocaleDateString()}
</small>
</div>
</div>
))}
</div>
)}
<div className="wallet-refund-card">
<h3>How refunds work</h3>
<p>
When a refund is approved, the credited amount will appear in
your wallet balance and in the activity list above.
</p>
</div>
</>
)}
</IonContent>
</IonPage>
);
};
export default Wallet;
+2 -2
View File
@@ -2,6 +2,6 @@ import { createClient } from '@supabase/supabase-js';
import type { Database } from './database.types';
export const supabase = createClient<Database>(
'https://ztrqscwsjnfvcuaakqus.supabase.co',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inp0cnFzY3dzam5mdmN1YWFrcXVzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Nzk4ODk5MzEsImV4cCI6MjA5NTQ2NTkzMX0.htIOqPM90Uk0dYIdD8X8_tLfq5URcSvmCArfVRF4jeQ',
'https://sxdbfylkgcfmsatqipli.supabase.co',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InN4ZGJmeWxrZ2NmbXNhdHFpcGxpIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODExMTI5NjcsImV4cCI6MjA5NjY4ODk2N30.8ZvpjRBskCIH3FFnOkWsxZCOwNMX5txinffgXMDinKI',
);
+51
View File
@@ -0,0 +1,51 @@
// Ambient test globals for optional *.test.* files in the editor / tsc.
// No test runner is installed in the template; stubs avoid spurious TS errors.
interface Matchers<R = void> {
toBeDefined(): R;
toBe(expected: unknown): R;
toEqual(expected: unknown): R;
toBeTruthy(): R;
toBeFalsy(): R;
toBeNull(): R;
toContain(item: unknown): R;
toHaveLength(length: number): R;
toMatch(expected: string | RegExp): R;
toThrow(expected?: string | RegExp | Error): R;
not: Matchers<R>;
}
interface Expect {
<T = unknown>(actual: T): Matchers;
}
declare const expect: Expect;
type TestCaseFn = (
name: string,
fn?: () => void | Promise<void>,
timeout?: number,
) => void;
declare const test: TestCaseFn;
declare const it: TestCaseFn;
declare const describe: TestCaseFn;
declare function beforeEach(fn: () => void | Promise<void>): void;
declare function afterEach(fn: () => void | Promise<void>): void;
declare function beforeAll(fn: () => void | Promise<void>): void;
declare function afterAll(fn: () => void | Promise<void>): void;
declare module "@testing-library/react" {
export function render(
ui: unknown,
options?: object,
): {
baseElement: Element;
container: Element;
unmount(): void;
rerender(ui: unknown): void;
};
export const screen: Record<string, (...args: unknown[]) => Element>;
export function cleanup(): void;
}
+193 -3899
View File
File diff suppressed because it is too large Load Diff
+73
View File
@@ -0,0 +1,73 @@
/**
* Date utilities for ShopKeep.
*/
/**
* Returns today's date in YYYY-MM-DD format (local time).
*/
export const getTodayISO = (): string => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
/**
* Formats a date string (YYYY-MM-DD) or ISO string for display.
*/
export const formatDisplayDate = (
dateStr: string,
options: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
year: 'numeric',
}
): string => {
if (!dateStr) return '';
const date = new Date(dateStr);
if (isNaN(date.getTime())) return dateStr;
return new Intl.DateTimeFormat('en-US', options).format(date);
};
/**
* Returns a relative date string (Today, Yesterday, or formatted date).
*/
export const formatRelativeDate = (dateStr: string): string => {
const today = getTodayISO();
const date = new Date(dateStr);
const dateISO = date.toISOString().split('T')[0];
if (dateISO === today) return 'Today';
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayISO = yesterday.toISOString().split('T')[0];
if (dateISO === yesterdayISO) return 'Yesterday';
return formatDisplayDate(dateStr);
};
/**
* Group items by date.
*/
export const groupItemsByDate = <T extends { date: string }>(
items: T[]
): { date: string; items: T[] }[] => {
const groups: Record<string, T[]> = {};
items.forEach((item) => {
if (!groups[item.date]) {
groups[item.date] = [];
}
groups[item.date].push(item);
});
return Object.keys(groups)
.sort((a, b) => b.localeCompare(a)) // Sort by date descending
.map((date) => ({
date,
items: groups[date],
}));
};
+65
View File
@@ -0,0 +1,65 @@
/**
* Money utilities for sanitizing, parsing, and formatting currency values.
*/
/**
* Sanitizes input string to allow only digits and one decimal point.
*/
export const sanitizeMoneyInput = (value: string): string => {
// Remove anything that isn't a digit or a period
let sanitized = value.replace(/[^0-9.]/g, '');
// Ensure only one period exists
const parts = sanitized.split('.');
if (parts.length > 2) {
sanitized = parts[0] + '.' + parts.slice(1).join('');
}
// Limit to 2 decimal places
if (parts.length === 2 && parts[1].length > 2) {
sanitized = parts[0] + '.' + parts[1].slice(0, 2);
}
return sanitized;
};
/**
* Parses a sanitized string into a number.
*/
export const parseMoney = (value: string): number => {
const parsed = parseFloat(value);
return isNaN(parsed) ? 0 : parsed;
};
/**
* Formats a number as currency.
*/
export const formatMoney = (
amount: number,
currency: string = 'USD',
locale: string = 'en-US'
): string => {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
};
/**
* Returns the currency symbol for a given currency code.
*/
export const getCurrencySymbol = (
currency: string = 'USD',
locale: string = 'en-US'
): string => {
return (
new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency,
})
.formatToParts(0)
.find((part) => part.type === 'currency')?.value || '$'
);
};
+5 -14
View File
@@ -1,18 +1,14 @@
import { StatusBar, Style } from '@capacitor/status-bar';
export { Style };
``
const _origin = (() => {
try {
if (document.referrer) return new URL(document.referrer).origin;
} catch {}
try { if (document.referrer) return new URL(document.referrer).origin; } catch {}
return window.location.origin;
})();
/** Set status bar icon/text colour. Use instead of StatusBar.setStyle directly. */
export async function setStatusBarStyle(style: Style): Promise<void> {
try {
await StatusBar.setStyle({ style });
} catch {}
try { await StatusBar.setStyle({ style }); } catch {}
if (window.parent !== window) {
window.parent.postMessage({ type: '__apsuite_statusbar', style }, _origin);
}
@@ -20,13 +16,8 @@ export async function setStatusBarStyle(style: Style): Promise<void> {
/** Set status bar background colour (Android). Use instead of StatusBar.setBackgroundColor directly. */
export async function setStatusBarBackground(color: string): Promise<void> {
try {
await (StatusBar as any).setBackgroundColor({ color });
} catch {}
try { await (StatusBar as any).setBackgroundColor({ color }); } catch {}
if (window.parent !== window) {
window.parent.postMessage(
{ type: '__apsuite_statusbar_bg', color },
_origin
);
window.parent.postMessage({ type: '__apsuite_statusbar_bg', color }, _origin);
}
}
+3
View File
@@ -1,3 +1,6 @@
// NOTE: @ionic/react and @ionic/react-router are pinned to 8.8.7 in package.json.
// 8.8.8 introduced a regression that breaks IonRouterOutlet page transition animations.
// Do not upgrade until the upstream fix is confirmed.
import { defineConfig, type Plugin } from 'vite';
import react from '@vitejs/plugin-react';
+68 -74
View File
@@ -1043,11 +1043,11 @@ __metadata:
linkType: hard
"@googlemaps/js-api-loader@npm:^2.0.2":
version: 2.1.0
resolution: "@googlemaps/js-api-loader@npm:2.1.0"
version: 2.1.1
resolution: "@googlemaps/js-api-loader@npm:2.1.1"
dependencies:
"@types/google.maps": "npm:^3.53.1"
checksum: 10c0/dc9593252f59af6c8af0056d1045bc6ce8335962fde51b4e294b864f47d3c640c4259c3afedfb9c0f60b62f76d9c8786062888c6f22518ce88f2e0d95dc6b510
checksum: 10c0/06cb6eaf686b838be51efd8293010b7bee42e341e9ad41b42a0ee8909cf06cc5711ba6cfdbcd0c62ff05c8ba35d8622c34130e738bc5844011c8bc457d14de64
languageName: node
linkType: hard
@@ -1086,6 +1086,15 @@ __metadata:
languageName: node
linkType: hard
"@hookform/resolvers@npm:^3.9.0":
version: 3.10.0
resolution: "@hookform/resolvers@npm:3.10.0"
peerDependencies:
react-hook-form: ^7.0.0
checksum: 10c0/7ee44533b4cdc28c4fa2a94894c735411e5a1f830f4a617c580533321a9b901df0cc8c1e2fad81ad8d55154ebc5cb844cf9c116a3148ffae2bc48758c33cbb8e
languageName: node
linkType: hard
"@ionic/cli-framework-output@npm:^2.2.8":
version: 2.2.8
resolution: "@ionic/cli-framework-output@npm:2.2.8"
@@ -1108,17 +1117,6 @@ __metadata:
languageName: node
linkType: hard
"@ionic/core@npm:8.8.8":
version: 8.8.8
resolution: "@ionic/core@npm:8.8.8"
dependencies:
"@stencil/core": "npm:4.43.0"
ionicons: "npm:^8.0.13"
tslib: "npm:^2.1.0"
checksum: 10c0/fea32925aaebec558c1c3bb9c30f8c0806d3480a67936e63cdcce8846138688d9b6b5b853ebe771132f0f79efef16568b17d11495f7997ea325a265a7e7d7af3
languageName: node
linkType: hard
"@ionic/pwa-elements@npm:^3.0.0":
version: 3.4.0
resolution: "@ionic/pwa-elements@npm:3.4.0"
@@ -1126,36 +1124,22 @@ __metadata:
languageName: node
linkType: hard
"@ionic/react-router@npm:^8.5.0":
version: 8.8.8
resolution: "@ionic/react-router@npm:8.8.8"
"@ionic/react-router@npm:8.8.7":
version: 8.8.7
resolution: "@ionic/react-router@npm:8.8.7"
dependencies:
"@ionic/react": "npm:8.8.8"
"@ionic/react": "npm:8.8.7"
tslib: "npm:*"
peerDependencies:
react: ">=16.8.6"
react-dom: ">=16.8.6"
react-router: ^5.0.1
react-router-dom: ^5.0.1
checksum: 10c0/be9555ec4dbfd0565cada39d42236eb0b9e2283a13f500b42d5c0c06d6198c17a7be385cfb2ce4f9e89236c71992c30169328b861943a3fe7344c672ad94ae0c
checksum: 10c0/302d6ad9c0d450cf6466ff18622aa7708af77c1c4a676d2ff39eca6add440fe96d36a157feae146c3da460c32e7799357320b3270740b050ab040a664b9f0ff2
languageName: node
linkType: hard
"@ionic/react@npm:8.8.8":
version: 8.8.8
resolution: "@ionic/react@npm:8.8.8"
dependencies:
"@ionic/core": "npm:8.8.8"
ionicons: "npm:^8.0.13"
tslib: "npm:*"
peerDependencies:
react: ">=16.8.6"
react-dom: ">=16.8.6"
checksum: 10c0/b1b8888f487dc564735adde57ef3cd054e6c9bc38601f133823e69cb98d8e51c99c8902fe48fdd11ee5521ae58b322f110a1fbc8b8fd9bc62e27fc0b806bddd0
languageName: node
linkType: hard
"@ionic/react@npm:^8.5.0":
"@ionic/react@npm:8.8.7":
version: 8.8.7
resolution: "@ionic/react@npm:8.8.7"
dependencies:
@@ -1757,21 +1741,21 @@ __metadata:
languageName: node
linkType: hard
"@supabase/auth-js@npm:2.106.2":
version: 2.106.2
resolution: "@supabase/auth-js@npm:2.106.2"
"@supabase/auth-js@npm:2.108.1":
version: 2.108.1
resolution: "@supabase/auth-js@npm:2.108.1"
dependencies:
tslib: "npm:2.8.1"
checksum: 10c0/dc82807064bcf751c5378a9cc08a77d3732505434847af8c323355b63d417bfbfaa0e807c17eea7d0b7e022c8dadd7c9aa4c886554d60c599298e928d1035a98
checksum: 10c0/8bf1bce42703b63284a0562d5a6588ac879b4fdeb477046a92fa9ba6fa0d75de7d85f4177d389654f68b812e359e9f0c9c3a659e7d37d96aaa2f6eba12d9cff0
languageName: node
linkType: hard
"@supabase/functions-js@npm:2.106.2":
version: 2.106.2
resolution: "@supabase/functions-js@npm:2.106.2"
"@supabase/functions-js@npm:2.108.1":
version: 2.108.1
resolution: "@supabase/functions-js@npm:2.108.1"
dependencies:
tslib: "npm:2.8.1"
checksum: 10c0/256ca5134b22b90edab4c7c1f3c5db3ba8e80adbb15c3725e5ed2456a39fe21af5b7cacb2966590f6fbd17812761d2086fcad5db09eba90490911d2a66f880f1
checksum: 10c0/da67a82c4a79e049fa3d077f1ca3917a58707fa35df642d39d8b23edf5056a2a2427ea201d3f49bb11eafb06413dfde552a6bf63b2da4865a421d44c690c0fe7
languageName: node
linkType: hard
@@ -1782,45 +1766,45 @@ __metadata:
languageName: node
linkType: hard
"@supabase/postgrest-js@npm:2.106.2":
version: 2.106.2
resolution: "@supabase/postgrest-js@npm:2.106.2"
"@supabase/postgrest-js@npm:2.108.1":
version: 2.108.1
resolution: "@supabase/postgrest-js@npm:2.108.1"
dependencies:
tslib: "npm:2.8.1"
checksum: 10c0/57c1a78428d59dfa7a89ef7901f28f756068ce9dd27ff97bf133be6ba77a4a1f13dab38bfa1a2e40cf5fab5c9d84bea394aff73514da0d6062b5b10732631127
checksum: 10c0/29168464f44a723175ef510410df36f993581229d617816dfd33eb9bf5378742910fb9c18508040188ba2a16c20988f84d6f722decd79e34190daea35a5374af
languageName: node
linkType: hard
"@supabase/realtime-js@npm:2.106.2":
version: 2.106.2
resolution: "@supabase/realtime-js@npm:2.106.2"
"@supabase/realtime-js@npm:2.108.1":
version: 2.108.1
resolution: "@supabase/realtime-js@npm:2.108.1"
dependencies:
"@supabase/phoenix": "npm:^0.4.2"
tslib: "npm:2.8.1"
checksum: 10c0/18cb4015e26e6eb10c45b8546c02eefdae0461123f05ff614d49107f97560b12c6e87a7cb810bc1a5e4618ad5286cc9cc5283800285e94356ea8e38a69910c10
checksum: 10c0/bb4cea92714389b6f5f4b626e4c3cbacee2a57c543b71217d3d3032bfe35574cd26fe305bfbde0426230ca3b887e2116c78d47aa37117160b2b9d5ba11564ded
languageName: node
linkType: hard
"@supabase/storage-js@npm:2.106.2":
version: 2.106.2
resolution: "@supabase/storage-js@npm:2.106.2"
"@supabase/storage-js@npm:2.108.1":
version: 2.108.1
resolution: "@supabase/storage-js@npm:2.108.1"
dependencies:
iceberg-js: "npm:^0.8.1"
tslib: "npm:2.8.1"
checksum: 10c0/efb0904f1311d568c6b75336771933eb9f4bea93a1cb0f90fe54422d00162d5793e404974eefc509c93dba3f337d9d6897d6ed228657590150a1346eae8f01f7
checksum: 10c0/5a010b4d3af565b26ceb1d07e3d3f0541c8fe538350931c4fc15d0b560b3e40c354cc66259882a8b378e70dace59bc83dd53849feb48509155b8b342e00724a2
languageName: node
linkType: hard
"@supabase/supabase-js@npm:^2.0.0":
version: 2.106.2
resolution: "@supabase/supabase-js@npm:2.106.2"
version: 2.108.1
resolution: "@supabase/supabase-js@npm:2.108.1"
dependencies:
"@supabase/auth-js": "npm:2.106.2"
"@supabase/functions-js": "npm:2.106.2"
"@supabase/postgrest-js": "npm:2.106.2"
"@supabase/realtime-js": "npm:2.106.2"
"@supabase/storage-js": "npm:2.106.2"
checksum: 10c0/3d9418360b43cdf950c4d7496b368dd1c526cfc717dd3016ac032b904367476f31495c75c6ae905ef49e8b5b375416cb5cc354fb888d1e2fc596611824df2e97
"@supabase/auth-js": "npm:2.108.1"
"@supabase/functions-js": "npm:2.108.1"
"@supabase/postgrest-js": "npm:2.108.1"
"@supabase/realtime-js": "npm:2.108.1"
"@supabase/storage-js": "npm:2.108.1"
checksum: 10c0/9b8c3dd7153b4eb3c0bf47296e326c3e57bf77b979a5dc5d18b96a25c041195de942d227d13a5ce83a20203163580204e9102f2346f0a9461de4042b95f69f03
languageName: node
linkType: hard
@@ -1889,9 +1873,9 @@ __metadata:
linkType: hard
"@types/google.maps@npm:^3.53.1, @types/google.maps@npm:^3.58.1":
version: 3.65.0
resolution: "@types/google.maps@npm:3.65.0"
checksum: 10c0/d41bc1ed2c63f297a8956b097953515e87aa21d25ef918102067efa97113ea558bb42be521337af93698b36d4c6283439b3e087b4b57c7033f59128cdc7159cb
version: 3.65.1
resolution: "@types/google.maps@npm:3.65.1"
checksum: 10c0/9a7da04d9f27be7a3f9a4de2e6dd9271b68b83aa8a6050fc53ec5cc1366669ead4c27185200adf56f07d69c9a2bba72d82a0cf706e41ed4e7e89d98c44c5c6a1
languageName: node
linkType: hard
@@ -1909,7 +1893,7 @@ __metadata:
languageName: node
linkType: hard
"@types/node@npm:*, @types/node@npm:>=12.12.47, @types/node@npm:>=13.7.0":
"@types/node@npm:*":
version: 25.9.1
resolution: "@types/node@npm:25.9.1"
dependencies:
@@ -1918,6 +1902,15 @@ __metadata:
languageName: node
linkType: hard
"@types/node@npm:>=12.12.47, @types/node@npm:>=13.7.0":
version: 25.9.2
resolution: "@types/node@npm:25.9.2"
dependencies:
undici-types: "npm:>=7.24.0 <7.24.7"
checksum: 10c0/f14c0d56361febb985eccc45cf0834ee6e2f07c4389a636f3e1a55ebde320077a80bface18c9afd3092f5fa295925502c1a9d55f805efa813f634aa9c941cbac
languageName: node
linkType: hard
"@types/react-dom@npm:^19.0.0":
version: 19.2.3
resolution: "@types/react-dom@npm:19.2.3"
@@ -2043,9 +2036,10 @@ __metadata:
"@capacitor/keyboard": "npm:8.0.3"
"@capacitor/push-notifications": "npm:^8.1.1"
"@capacitor/status-bar": "npm:8.0.2"
"@hookform/resolvers": "npm:^3.9.0"
"@ionic/pwa-elements": "npm:^3.0.0"
"@ionic/react": "npm:^8.5.0"
"@ionic/react-router": "npm:^8.5.0"
"@ionic/react": "npm:8.8.7"
"@ionic/react-router": "npm:8.8.7"
"@supabase/supabase-js": "npm:^2.0.0"
"@types/katex": "npm:^0.16.0"
"@types/react": "npm:^19.0.0"
@@ -2994,8 +2988,8 @@ __metadata:
linkType: hard
"protobufjs@npm:^7.2.5":
version: 7.6.1
resolution: "protobufjs@npm:7.6.1"
version: 7.6.3
resolution: "protobufjs@npm:7.6.3"
dependencies:
"@protobufjs/aspromise": "npm:^1.1.2"
"@protobufjs/base64": "npm:^1.1.2"
@@ -3009,7 +3003,7 @@ __metadata:
"@protobufjs/utf8": "npm:^1.1.1"
"@types/node": "npm:>=13.7.0"
long: "npm:^5.3.2"
checksum: 10c0/364c6914facac19ace915e353f71c5d5996bfa8177a3a68c09bd2513a3ecf780538aca06cb3439237f50489db2fae321c4a4db60fd7f9ec65315b7ad66bea029
checksum: 10c0/e1132b1ff82684280041550d720398568763ff40928def03e8bb409eaed6edd222e63372e3f52d7c19e5a8e9975333480e26950759086eb5a1f5b5c118da7bf8
languageName: node
linkType: hard
@@ -3593,13 +3587,13 @@ __metadata:
linkType: hard
"websocket-driver@npm:>=0.5.1":
version: 0.7.4
resolution: "websocket-driver@npm:0.7.4"
version: 0.7.5
resolution: "websocket-driver@npm:0.7.5"
dependencies:
http-parser-js: "npm:>=0.5.1"
safe-buffer: "npm:>=5.1.0"
websocket-extensions: "npm:>=0.1.1"
checksum: 10c0/5f09547912b27bdc57bac17b7b6527d8993aa4ac8a2d10588bb74aebaf785fdcf64fea034aae0c359b7adff2044dd66f3d03866e4685571f81b13e548f9021f1
checksum: 10c0/843a3c9f529ce07f2721829f70f62986247525ab22625e857b69da13dc90967bacfe57b85e196801b565cd797e309ddd5b06fa8435732e34e8a8ab6201d7abed
languageName: node
linkType: hard