Compare commits

..

1 Commits

Author SHA1 Message Date
AppCakes c8ec54c22d build: da8aa2fb-9871-4e3e-a583-70eda77c4d68 2026-06-12 11:55:41 +00:00
33 changed files with 4666 additions and 2992 deletions
Binary file not shown.
+1 -1
View File
@@ -4,7 +4,7 @@ android {
namespace = "io.ionic.starter" namespace = "io.ionic.starter"
compileSdk = rootProject.ext.compileSdkVersion compileSdk = rootProject.ext.compileSdkVersion
defaultConfig { defaultConfig {
applicationId "com.qoutelink.app" applicationId "io.shopkeep.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1 versionCode 1
+1 -1
View File
@@ -25,7 +25,7 @@
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.qoutelink.app" /> <data android:scheme="io.shopkeep.app" />
</intent-filter> </intent-filter>
</activity> </activity>
+2 -2
View File
@@ -1,7 +1,7 @@
<?xml version='1.0' encoding='utf-8'?> <?xml version='1.0' encoding='utf-8'?>
<resources> <resources>
<string name="app_name">Qoutelink</string> <string name="app_name">ShopKeep</string>
<string name="title_activity_main">Qoutelink</string> <string name="title_activity_main">ShopKeep</string>
<string name="package_name">io.ionic.starter</string> <string name="package_name">io.ionic.starter</string>
<string name="custom_url_scheme">io.ionic.starter</string> <string name="custom_url_scheme">io.ionic.starter</string>
</resources> </resources>
+2 -2
View File
@@ -1,8 +1,8 @@
import type { CapacitorConfig } from '@capacitor/cli'; import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = { const config: CapacitorConfig = {
appId: 'com.qoutelink.app', appId: 'io.shopkeep.app',
appName: 'Qoutelink', appName: 'ShopKeep',
webDir: 'dist', webDir: 'dist',
plugins: { plugins: {
FirebaseAuthentication: { FirebaseAuthentication: {
+15 -2826
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -308,7 +308,7 @@
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.qoutelink.app; PRODUCT_BUNDLE_IDENTIFIER = io.shopkeep.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -330,7 +330,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.qoutelink.app; PRODUCT_BUNDLE_IDENTIFIER = io.shopkeep.app;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
+3 -3
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>en</string> <string>en</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Qoutelink</string> <string>ShopKeep</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@@ -51,10 +51,10 @@
<array> <array>
<dict> <dict>
<key>CFBundleURLName</key> <key>CFBundleURLName</key>
<string>com.qoutelink.app</string> <string>io.shopkeep.app</string>
<key>CFBundleURLSchemes</key> <key>CFBundleURLSchemes</key>
<array> <array>
<string>com.qoutelink.app</string> <string>io.shopkeep.app</string>
</array> </array>
</dict> </dict>
</array> </array>
+142 -19
View File
@@ -1,32 +1,155 @@
import React from "react"; import React, { useEffect } from 'react';
import { Redirect, Route } from "react-router-dom"; import { Redirect, Route } from 'react-router-dom';
import { IonApp, IonRouterOutlet } from "@ionic/react"; import {
import { IonReactRouter } from "@ionic/react-router"; 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 "@ionic/react/css/core.css"; import Home from './pages/Home';
import "@ionic/react/css/normalize.css"; import CustomersPage from './pages/CustomersPage';
import "@ionic/react/css/structure.css"; import CustomerDetailPage from './pages/CustomerDetailPage';
import "@ionic/react/css/typography.css"; import HistoryPage from './pages/HistoryPage';
import "@ionic/react/css/padding.css"; import SaleFormPage from './pages/SaleFormPage';
import "@ionic/react/css/float-elements.css"; import CreditFormPage from './pages/CreditFormPage';
import "@ionic/react/css/text-alignment.css"; import RepaymentFormPage from './pages/RepaymentFormPage';
import "@ionic/react/css/text-transformation.css"; import AuthPage from './pages/AuthPage';
import "@ionic/react/css/flex-utils.css"; import VerifyEmailPage from './pages/VerifyEmailPage';
import "@ionic/react/css/display.css"; import ForgotPasswordPage from './pages/ForgotPasswordPage';
import NewPasswordPage from './pages/NewPasswordPage';
import "./theme/variables.css"; import { AuthProvider } from './contexts/AuthContext';
import ProtectedRoute from './components/ProtectedRoute';
import { setStatusBarStyle, Style } from './utils/statusBar';
import Home from "./pages/Home"; /* Core CSS required for Ionic components to work properly */
import '@ionic/react/css/core.css';
const App: React.FC = () => ( /* 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';
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';
setupIonicReact();
const App: React.FC = () => {
useEffect(() => {
// Set status bar to light icons on our dark-ish backgrounds
setStatusBarStyle(Style.Dark);
}, []);
return (
<IonApp> <IonApp>
<AuthProvider>
<IonReactRouter> <IonReactRouter>
<IonRouterOutlet> <IonRouterOutlet>
<Route path="/home" component={Home} exact /> {/* Auth Routes */}
<Redirect exact from="/" to="/home" /> <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> </IonRouterOutlet>
</IonReactRouter> </IonReactRouter>
</AuthProvider>
</IonApp> </IonApp>
); );
};
export default App; export default App;
+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;
+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;
+27
View File
@@ -0,0 +1,27 @@
import React from 'react';
import { Route, Redirect, RouteProps } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
interface ProtectedRouteProps extends RouteProps {
component: React.ComponentType<any>;
}
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
component: Component,
...rest
}) => {
const { user, loading } = useAuth();
return (
<Route
{...rest}
render={(props) => {
if (loading) return null; // AuthProvider already handles spinner, but double safety
return user ? <Component {...props} /> : <Redirect to="/auth" />;
}}
/>
);
};
export default ProtectedRoute;
+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;
};
+323
View File
@@ -0,0 +1,323 @@
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export type Database = {
// Allows to automatically instantiate createClient with right options
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
__InternalSupabase: {
PostgrestVersion: "14.5"
}
public: {
Tables: {
credit_customers: {
Row: {
created_at: string
id: string
name: string
notes: string | null
phone: string | null
updated_at: string
user_id: string
}
Insert: {
created_at?: string
id?: string
name: string
notes?: string | null
phone?: string | null
updated_at?: string
user_id: string
}
Update: {
created_at?: string
id?: string
name?: string
notes?: string | null
phone?: string | null
updated_at?: string
user_id?: string
}
Relationships: []
}
credits: {
Row: {
amount: number
created_at: string
credit_date: string
customer_id: string
description: string | null
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
updated_at?: string
user_id: string
}
Update: {
amount?: number
created_at?: string
credit_date?: string
customer_id?: string
description?: string | null
id?: string
updated_at?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "credits_customer_id_fkey"
columns: ["customer_id"]
isOneToOne: false
referencedRelation: "credit_customers"
referencedColumns: ["id"]
},
]
}
profiles: {
Row: {
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: {
amount: number
created_at?: string
customer_id: string
id?: string
note?: string | null
repayment_date?: string
updated_at?: string
user_id: string
}
Update: {
amount?: number
created_at?: 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
}
Relationships: []
}
}
Views: {
[_ in never]: never
}
Functions: {
[_ in never]: never
}
Enums: {
[_ in never]: never
}
CompositeTypes: {
[_ in never]: never
}
}
}
type DatabaseWithoutInternals = Omit<Database, "__InternalSupabase">
type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, "public">]
export type Tables<
DefaultSchemaTableNameOrOptions extends
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
Row: infer R
}
? R
: never
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
DefaultSchema["Views"])
? (DefaultSchema["Tables"] &
DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
Row: infer R
}
? R
: never
: never
export type TablesInsert<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Insert: infer I
}
? I
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Insert: infer I
}
? I
: never
: never
export type TablesUpdate<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Update: infer U
}
? U
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Update: infer U
}
? U
: never
: never
export type Enums<
DefaultSchemaEnumNameOrOptions extends
| keyof DefaultSchema["Enums"]
| { schema: keyof DatabaseWithoutInternals },
EnumName extends DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
: never = never,
> = DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
: never
export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends
| keyof DefaultSchema["CompositeTypes"]
| { schema: keyof DatabaseWithoutInternals },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
: never = never,
> = PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
: never
export const Constants = {
public: {
Enums: {},
},
} as const
+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;
+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;
+349 -19
View File
@@ -1,30 +1,360 @@
import React from "react"; import React, { useState, useCallback } from 'react';
import { import {
IonContent, IonContent,
IonHeader,
IonPage, IonPage,
IonTitle, IonIcon,
IonToolbar, IonButton,
} from "@ionic/react"; IonActionSheet,
IonRefresher,
IonRefresherContent,
useIonViewWillEnter,
} from '@ionic/react';
import {
addCircleOutline,
personAddOutline,
cashOutline,
receiptOutline,
walletOutline,
checkmarkCircleOutline,
personCircleOutline,
} from 'ionicons/icons';
import { useHistory } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { supabase } from '../supabase';
import MetricCard from '../components/MetricCard';
import ActivityRow, { ActivityItem } from '../components/ActivityRow';
import EmptyState from '../components/EmptyState';
import { getTodayISO } from '../utils/dates';
const Home: React.FC = () => ( const Home: React.FC = () => {
const { user, profile } = useAuth();
const history = useHistory();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showQuickActions, setShowQuickActions] = useState(false);
const [summary, setSummary] = useState({
todaySales: 0,
outstandingCredit: 0,
monthRepayments: 0,
recentActivity: [] as ActivityItem[],
});
const fetchData = useCallback(async () => {
if (!user) return;
setLoading(true);
setError(null);
try {
const today = getTodayISO();
const firstOfMonth = new Date();
firstOfMonth.setDate(1);
const firstOfMonthISO = firstOfMonth.toISOString().split('T')[0];
// Parallel queries for efficiency
const [
{ data: salesToday },
{ data: creditsData },
{ data: repaymentsData },
{ data: recentSales },
{ data: recentCredits },
{ data: recentRepayments },
] = await Promise.all([
supabase.from('sales').select('amount').eq('sale_date', today),
supabase.from('credits').select('amount'),
supabase.from('repayments').select('amount, repayment_date'),
supabase
.from('sales')
.select('*')
.order('created_at', { ascending: false })
.limit(5),
supabase
.from('credits')
.select('*, credit_customers(name)')
.order('created_at', { ascending: false })
.limit(5),
supabase
.from('repayments')
.select('*, credit_customers(name)')
.order('created_at', { ascending: false })
.limit(5),
]);
// Calculate totals
const todaySalesTotal = (salesToday || []).reduce(
(sum, s) => sum + Number(s.amount),
0
);
const totalCredits = (creditsData || []).reduce(
(sum, c) => sum + Number(c.amount),
0
);
const totalRepayments = (repaymentsData || []).reduce(
(sum, r) => sum + Number(r.amount),
0
);
const monthRepaymentsTotal = (repaymentsData || [])
.filter((r) => r.repayment_date >= firstOfMonthISO)
.reduce((sum, r) => sum + Number(r.amount), 0);
// Combine activity
const activity: ActivityItem[] = [
...(recentSales || []).map((s) => ({
id: s.id,
type: 'sale' as const,
amount: Number(s.amount),
date: s.sale_date,
note: s.note,
})),
...(recentCredits || []).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,
})),
...(recentRepayments || []).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.id.localeCompare(a.id)); // Simplified sort for MVP, ideally use created_at
setSummary({
todaySales: todaySalesTotal,
outstandingCredit: Math.max(0, totalCredits - totalRepayments),
monthRepayments: monthRepaymentsTotal,
recentActivity: activity.slice(0, 10),
});
} catch (err: any) {
console.error('Error fetching dashboard data:', err);
setError('Could not load dashboard data');
} finally {
setLoading(false);
}
}, [user]);
useIonViewWillEnter(() => {
fetchData();
}, [fetchData]);
const handleRefresh = async (e: CustomEvent) => {
await fetchData();
e.detail.complete();
};
const getGreeting = () => {
const hour = new Date().getHours();
if (hour < 12) return 'Good morning';
if (hour < 17) return 'Good afternoon';
return 'Good evening';
};
return (
<IonPage> <IonPage>
<IonHeader translucent className="home-header"> <IonContent
<IonToolbar className="home-toolbar"> fullscreen
<IonTitle>Home</IonTitle> style={{
</IonToolbar> '--background':
</IonHeader> 'linear-gradient(160deg, rgba(199,210,254,0.45) 0%, #f0f4ff 58%, #f8fafc 100%)',
<IonContent fullscreen className="home-content"> }}
<div className="home-shell minimal-home-shell"> >
<section className="minimal-welcome-card subtle-welcome-card"> <IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
<h1>Welcome to your new app</h1> <IonRefresherContent />
<p className="minimal-subtitle"> </IonRefresher>
Chat with the assistant to start adding features and pages
</p> <div
</section> style={{
padding: 'calc(16px + var(--ion-safe-area-top)) 20px 16px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div>
<div className="secondary-text">{getGreeting()}</div>
<h1 className="bold" style={{ margin: '0', fontSize: '20px' }}>
{profile?.shop_name || profile?.full_name || 'My Shop'}
</h1>
</div> </div>
<div
className="icon-container"
style={{
borderRadius: '999px',
background: 'var(--color-accent-soft)',
}}
onClick={() => setShowQuickActions(true)}
>
<IonIcon icon={personCircleOutline} style={{ fontSize: '24px' }} />
</div>
</div>
<div className="page-padding">
<MetricCard
label="Today's Sales"
value={summary.todaySales}
fullWidth
loading={loading}
/>
<div className="metric-pair-row" style={{ marginTop: '12px' }}>
<MetricCard
label="Outstanding"
value={summary.outstandingCredit}
icon={walletOutline}
loading={loading}
/>
<MetricCard
label="Repaid (Month)"
value={summary.monthRepayments}
icon={checkmarkCircleOutline}
loading={loading}
/>
</div>
<div
className="pill-toggle"
style={{ marginTop: '12px', background: '#ffffff', padding: '8px' }}
>
<IonButton
fill="clear"
className="bold"
style={{
flex: 1,
fontSize: '13px',
color: 'var(--color-text-primary)',
}}
onClick={() => history.push('/sale/new')}
>
<IonIcon icon={addCircleOutline} slot="start" />
Add Sale
</IonButton>
<IonButton
fill="clear"
className="bold"
style={{
flex: 1,
fontSize: '13px',
color: 'var(--color-text-primary)',
}}
onClick={() => history.push('/credit/new')}
>
<IonIcon icon={personAddOutline} slot="start" />
Credit
</IonButton>
<IonButton
fill="clear"
className="bold"
style={{
flex: 1,
fontSize: '13px',
color: 'var(--color-text-primary)',
}}
onClick={() => history.push('/repayment/new')}
>
<IonIcon icon={cashOutline} slot="start" />
Repay
</IonButton>
</div>
</div>
<div className="section-header">
<h2>Recent activity</h2>
<span className="see-all" onClick={() => history.push('/history')}>
See all
</span>
</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: '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>
) : summary.recentActivity.length > 0 ? (
<div className="widget-card" style={{ padding: '8px 16px' }}>
{summary.recentActivity.map((item) => (
<ActivityRow
key={`${item.type}-${item.id}`}
item={item}
onClick={
item.customerId
? () => history.push(`/customers/${item.customerId}`)
: undefined
}
/>
))}
</div>
) : (
<EmptyState
icon={receiptOutline}
title="No activity yet"
description="Record your first sale or credit to see it here."
actionText="Add Sale"
onAction={() => history.push('/sale/new')}
/>
)}
</div>
<IonActionSheet
isOpen={showQuickActions}
onDidDismiss={() => setShowQuickActions(false)}
header="Settings"
buttons={[
{
text: 'Sign Out',
role: 'destructive',
handler: () => {
supabase.auth.signOut();
},
},
{
text: 'Cancel',
role: 'cancel',
},
]}
/>
</IonContent> </IonContent>
</IonPage> </IonPage>
); );
};
export default Home; export default Home;
+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;
+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;
+7
View File
@@ -0,0 +1,7 @@
import { createClient } from '@supabase/supabase-js';
import type { Database } from './database.types';
export const supabase = createClient<Database>(
'https://sxdbfylkgcfmsatqipli.supabase.co',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InN4ZGJmeWxrZ2NmbXNhdHFpcGxpIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODExMTI5NjcsImV4cCI6MjA5NjY4ODk2N30.8ZvpjRBskCIH3FFnOkWsxZCOwNMX5txinffgXMDinKI',
);
+239 -100
View File
@@ -1,25 +1,26 @@
/* Ionic CSS Variablescustomise to match your app's brand */ /* ShopKeepCustom Palette & Shared Styles */
:root { :root {
--ion-color-primary: #5e6ad2; /** ShopKeep Brand Palette **/
--ion-color-primary-rgb: 94, 106, 210; --ion-color-primary: #6366f1;
--ion-color-primary-rgb: 99, 102, 241;
--ion-color-primary-contrast: #ffffff; --ion-color-primary-contrast: #ffffff;
--ion-color-primary-contrast-rgb: 255, 255, 255; --ion-color-primary-contrast-rgb: 255, 255, 255;
--ion-color-primary-shade: #535db9; --ion-color-primary-shade: #5759d4;
--ion-color-primary-tint: #6e79d7; --ion-color-primary-tint: #7375f2;
--ion-color-secondary: #7a89f4; --ion-color-secondary: #818cf8;
--ion-color-secondary-rgb: 122, 137, 244; --ion-color-secondary-rgb: 129, 140, 248;
--ion-color-secondary-contrast: #ffffff; --ion-color-secondary-contrast: #ffffff;
--ion-color-secondary-contrast-rgb: 255, 255, 255; --ion-color-secondary-contrast-rgb: 255, 255, 255;
--ion-color-secondary-shade: #6b79d7; --ion-color-secondary-shade: #727bda;
--ion-color-secondary-tint: #8895f5; --ion-color-secondary-tint: #8e98f9;
--ion-color-tertiary: #8f78d8; --ion-color-tertiary: #c7d2fe;
--ion-color-tertiary-rgb: 143, 120, 216; --ion-color-tertiary-rgb: 199, 210, 254;
--ion-color-tertiary-contrast: #ffffff; --ion-color-tertiary-contrast: #111827;
--ion-color-tertiary-contrast-rgb: 255, 255, 255; --ion-color-tertiary-contrast-rgb: 17, 24, 39;
--ion-color-tertiary-shade: #7e6ac0; --ion-color-tertiary-shade: #afb9e0;
--ion-color-tertiary-tint: #9a86dc; --ion-color-tertiary-tint: #cdd7fe;
--ion-color-success: #3aa67a; --ion-color-success: #3aa67a;
--ion-color-success-rgb: 58, 166, 122; --ion-color-success-rgb: 58, 166, 122;
@@ -28,13 +29,6 @@
--ion-color-success-shade: #33926b; --ion-color-success-shade: #33926b;
--ion-color-success-tint: #4eb088; --ion-color-success-tint: #4eb088;
--ion-color-warning: #dfb25a;
--ion-color-warning-rgb: 223, 178, 90;
--ion-color-warning-contrast: #2d2520;
--ion-color-warning-contrast-rgb: 45, 37, 32;
--ion-color-warning-shade: #c49d4f;
--ion-color-warning-tint: #e2bb6b;
--ion-color-danger: #cf6679; --ion-color-danger: #cf6679;
--ion-color-danger-rgb: 207, 102, 121; --ion-color-danger-rgb: 207, 102, 121;
--ion-color-danger-contrast: #ffffff; --ion-color-danger-contrast: #ffffff;
@@ -42,111 +36,256 @@
--ion-color-danger-shade: #b65a6a; --ion-color-danger-shade: #b65a6a;
--ion-color-danger-tint: #d57587; --ion-color-danger-tint: #d57587;
--ion-color-dark: #1f2430;
--ion-color-dark-rgb: 31, 36, 48;
--ion-color-dark-contrast: #ffffff;
--ion-color-dark-contrast-rgb: 255, 255, 255;
--ion-color-dark-shade: #1b202a;
--ion-color-dark-tint: #353946;
--ion-color-medium: #7f8697;
--ion-color-medium-rgb: 127, 134, 151;
--ion-color-medium-contrast: #ffffff;
--ion-color-medium-contrast-rgb: 255, 255, 255;
--ion-color-medium-shade: #707686;
--ion-color-medium-tint: #8c93a2;
--ion-color-light: #f3f5fb; --ion-color-light: #f3f5fb;
--ion-color-light-rgb: 243, 245, 251; --ion-color-light-rgb: 243, 245, 251;
--ion-color-light-contrast: #1f2430; --ion-color-light-contrast: #111827;
--ion-color-light-contrast-rgb: 31, 36, 48; --ion-color-light-contrast-rgb: 17, 24, 39;
--ion-color-light-shade: #d6d8dd; --ion-color-light-shade: #d6d8dd;
--ion-color-light-tint: #f4f6fb; --ion-color-light-tint: #f4f6fb;
--app-background: #ffffff; /** App Custom Tokens **/
--app-surface: #ffffff; --color-bg: #f0f4ff;
--app-surface-secondary: #f6f7fb; --color-surface: #ffffff;
--app-text-primary: #202532; --color-surface-secondary: #e8edf8;
--app-text-secondary: rgba(32, 37, 50, 0.68); --color-text-primary: #111827;
--app-text-tertiary: rgba(32, 37, 50, 0.46); --color-text-secondary: rgba(17, 24, 39, 0.6);
--app-accent-soft: rgba(94, 106, 210, 0.12); --color-text-tertiary: rgba(17, 24, 39, 0.38);
--app-accent-softer: rgba(94, 106, 210, 0.08); --color-accent-soft: rgba(99, 102, 241, 0.18);
--app-success-soft: rgba(58, 166, 122, 0.12); --color-success-soft: rgba(58, 166, 122, 0.12);
--app-shadow: 0 18px 48px rgba(82, 95, 140, 0.08); --color-danger-soft: rgba(207, 102, 121, 0.12);
/** Radii **/
--radius-pill: 999px;
--radius-card: 24px;
--radius-inner: 12px;
/** Typography **/
--font-tabular: 'SF Mono', 'Roboto Mono', Menlo, monospace;
} }
body { body {
background: var(--app-background); background: var(--color-bg);
color: var(--app-text-primary); color: var(--color-text-primary);
font-family:
-apple-system, BlinkMacSystemFont, 'Inter', Roboto, Helvetica, Arial,
sans-serif;
} }
ion-content.home-content { /** Shared Auth Styles **/
--background: var(--app-background); .auth-shell {
}
.home-toolbar {
--background: rgba(255, 255, 255, 0.92);
--border-width: 0;
--border-color: transparent;
--color: var(--app-text-primary);
box-shadow: none;
backdrop-filter: blur(18px);
}
ion-header.home-header::after {
display: none;
}
.home-shell {
padding: calc(88px + var(--ion-safe-area-top)) 24px
calc(32px + var(--ion-safe-area-bottom));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} gap: 24px;
padding: calc(var(--ion-safe-area-top, 0px) + 18px) 20px
.minimal-home-shell { calc(var(--ion-safe-area-bottom, 0px) + 28px);
min-height: 100%; min-height: 100%;
justify-content: flex-start;
} }
.minimal-welcome-card { .auth-card {
text-align: left; background: var(--color-surface);
border-radius: var(--radius-card);
padding: 24px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 20px;
} }
.subtle-welcome-card { /** Shared Form Styles **/
max-width: 420px; .form-card {
margin: 0; background: var(--color-surface);
padding: 28px 24px; border-radius: var(--radius-card);
background: var(--app-surface-secondary); padding: 20px;
border-radius: 28px; display: flex;
flex-direction: column;
gap: 16px;
} }
.minimal-welcome-card h1 { .form-field {
margin: 0; display: flex;
color: var(--app-text-primary); flex-direction: column;
font-size: 32px; }
line-height: 1.12;
letter-spacing: -0.03em; .input-wrapper {
background: var(--color-surface-secondary);
border-radius: var(--radius-inner);
padding: 0 16px;
min-height: 52px;
display: flex;
align-items: center;
}
.input-wrapper ion-input,
.input-wrapper ion-textarea,
.input-wrapper ion-select {
--padding-start: 0;
--padding-end: 0;
--placeholder-color: var(--color-text-tertiary);
--placeholder-opacity: 1;
color: var(--color-text-primary);
}
.money-input-container {
display: flex;
flex-direction: column;
}
/** Widget Styles **/
.widget-card {
background: var(--color-surface);
border-radius: var(--radius-card);
padding: 20px;
display: flex;
flex-direction: column;
}
.metric-pair-row {
display: flex;
gap: 12px;
margin-bottom: 12px;
}
.metric-pair-row .widget-card {
flex: 1;
padding: 16px;
}
.skeleton-text {
animation: skeleton-loading 1s linear infinite alternate;
}
@keyframes skeleton-loading {
0% {
background-color: var(--color-surface-secondary);
opacity: 0.5;
}
100% {
background-color: var(--color-surface-secondary);
opacity: 1;
}
}
/** Icon Containers **/
.icon-container {
width: 40px;
height: 40px;
border-radius: var(--radius-inner);
background: var(--color-accent-soft);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--ion-color-primary);
}
.icon-container ion-icon {
font-size: 20px;
}
/** Tab Bar Styling **/
ion-tab-bar {
--background: var(--color-surface);
--border-width: 0;
height: calc(64px + var(--ion-safe-area-bottom));
padding-bottom: var(--ion-safe-area-bottom);
border-top-left-radius: var(--radius-card);
border-top-right-radius: var(--radius-card);
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.04);
}
ion-tab-button {
--color: var(--color-text-tertiary);
--color-selected: var(--ion-color-primary);
}
ion-tab-button ion-label {
font-size: 11px;
font-weight: 600;
margin-top: 4px;
}
ion-tab-button ion-icon {
font-size: 24px;
}
/** Generic Utilities **/
.tabular {
font-family: var(--font-tabular);
}
.semibold {
font-weight: 600;
}
.bold {
font-weight: 700; font-weight: 700;
} }
.minimal-subtitle { .tertiary-text {
color: var(--color-text-tertiary);
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.05em;
font-weight: 700;
}
.secondary-text {
color: var(--color-text-secondary);
font-size: 13px;
}
.pill-toggle {
background: var(--color-surface-secondary);
border-radius: var(--radius-pill);
display: flex;
padding: 4px;
gap: 4px;
}
.pill-toggle-btn {
flex: 1;
border-radius: var(--radius-pill);
height: 36px;
border: none;
font-size: 13px;
font-weight: 600;
color: var(--color-text-secondary);
background: transparent;
transition: all 0.2s ease;
}
.pill-toggle-btn.active {
background: var(--ion-color-primary);
color: #ffffff;
}
/** Page Spacing **/
.page-padding {
padding: 0 20px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin: 24px 20px 12px;
}
.section-header h2 {
margin: 0; margin: 0;
color: var(--app-text-secondary); font-size: 16px;
font-size: 15px; font-weight: 700;
line-height: 1.55; color: var(--color-text-primary);
} }
@media (min-width: 680px) { .section-header .see-all {
.home-shell { color: var(--ion-color-primary);
max-width: 720px; font-size: 13px;
margin: 0 auto; font-weight: 600;
text-decoration: none;
} }
.minimal-welcome-card h1 { /** Custom Searchbar **/
font-size: 40px; .custom-searchbar {
} --padding-start: 0;
--padding-end: 0;
} }
+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 || '$'
);
};
+3 -3
View File
@@ -1903,11 +1903,11 @@ __metadata:
linkType: hard linkType: hard
"@types/node@npm:>=12.12.47, @types/node@npm:>=13.7.0": "@types/node@npm:>=12.12.47, @types/node@npm:>=13.7.0":
version: 25.9.3 version: 25.9.2
resolution: "@types/node@npm:25.9.3" resolution: "@types/node@npm:25.9.2"
dependencies: dependencies:
undici-types: "npm:>=7.24.0 <7.24.7" undici-types: "npm:>=7.24.0 <7.24.7"
checksum: 10c0/72d3aece9d42c2c641bcd3f3cb2dc2828b4bd384dfcbd910c404b8859a68bd69d50c4769ce7defd4ff5e049768e23e615f09407ea2cbbb5f44b90d75a7c6b8ca checksum: 10c0/f14c0d56361febb985eccc45cf0834ee6e2f07c4389a636f3e1a55ebde320077a80bface18c9afd3092f5fa295925502c1a9d55f805efa813f634aa9c941cbac
languageName: node languageName: node
linkType: hard linkType: hard