Launch
This commit is contained in:
@@ -4,274 +4,303 @@ import {
|
||||
BillingSummary,
|
||||
HealthCheckResponse,
|
||||
PurchaseProductId,
|
||||
RevenueCatCustomerInfo,
|
||||
ScanPlantResponse,
|
||||
SemanticSearchResponse,
|
||||
ServiceHealthResponse,
|
||||
SimulatedWebhookEvent,
|
||||
SimulatePurchaseResponse,
|
||||
SimulateWebhookResponse,
|
||||
SyncRevenueCatStateResponse,
|
||||
} from './contracts';
|
||||
import { getAuthToken } from './userIdentityService';
|
||||
import { mockBackendService } from './mockBackendService';
|
||||
import { CareInfo, Language } from '../../types';
|
||||
|
||||
const BACKEND_BASE_URL = (process.env.EXPO_PUBLIC_BACKEND_URL || process.env.EXPO_PUBLIC_PAYMENT_SERVER_URL || '').trim();
|
||||
const REQUEST_TIMEOUT_MS = 15000;
|
||||
|
||||
const mapHttpStatusToErrorCode = (status: number): BackendErrorCode => {
|
||||
if (status === 400) return 'BAD_REQUEST';
|
||||
if (status === 401 || status === 403) return 'UNAUTHORIZED';
|
||||
if (status === 402) return 'INSUFFICIENT_CREDITS';
|
||||
if (status === 408 || status === 504) return 'TIMEOUT';
|
||||
return 'PROVIDER_ERROR';
|
||||
};
|
||||
|
||||
const buildBackendUrl = (path: string): string => {
|
||||
const base = BACKEND_BASE_URL.replace(/\/$/, '');
|
||||
return `${base}${path}`;
|
||||
};
|
||||
|
||||
const parseMaybeJson = (value: string): Record<string, unknown> | null => {
|
||||
if (!value) return null;
|
||||
try {
|
||||
return JSON.parse(value) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const makeRequest = async <T,>(
|
||||
path: string,
|
||||
options: {
|
||||
method: 'GET' | 'POST';
|
||||
token: string;
|
||||
idempotencyKey?: string;
|
||||
body?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<T> => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${options.token}`,
|
||||
};
|
||||
if (options.idempotencyKey) {
|
||||
headers['Idempotency-Key'] = options.idempotencyKey;
|
||||
}
|
||||
|
||||
const response = await fetch(buildBackendUrl(path), {
|
||||
method: options.method,
|
||||
headers,
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const rawText = await response.text();
|
||||
const jsonPayload = parseMaybeJson(rawText);
|
||||
|
||||
if (!response.ok) {
|
||||
const payloadCode = jsonPayload?.code;
|
||||
const code = typeof payloadCode === 'string'
|
||||
? payloadCode as BackendErrorCode
|
||||
: mapHttpStatusToErrorCode(response.status);
|
||||
const payloadMessage = jsonPayload?.message;
|
||||
const message = typeof payloadMessage === 'string'
|
||||
? payloadMessage
|
||||
: `Backend request failed with status ${response.status}.`;
|
||||
throw new BackendApiError(code, message, response.status, jsonPayload || undefined);
|
||||
}
|
||||
|
||||
if (jsonPayload == null) {
|
||||
throw new BackendApiError('PROVIDER_ERROR', 'Backend returned invalid JSON.', 502);
|
||||
}
|
||||
|
||||
return jsonPayload as T;
|
||||
} catch (error) {
|
||||
if (error instanceof BackendApiError) throw error;
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new BackendApiError('TIMEOUT', 'Backend request timed out.', 408);
|
||||
}
|
||||
throw new BackendApiError('PROVIDER_ERROR', 'Backend request failed unexpectedly.', 500, {
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
|
||||
export const backendApiClient = {
|
||||
getServiceHealth: async (): Promise<ServiceHealthResponse> => {
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return {
|
||||
ok: true,
|
||||
uptimeSec: 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
openAiConfigured: Boolean(process.env.EXPO_PUBLIC_OPENAI_API_KEY),
|
||||
dbReady: true,
|
||||
dbPath: 'in-app-mock-backend',
|
||||
stripeConfigured: Boolean(process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY),
|
||||
scanModel: (process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim(),
|
||||
healthModel: (process.env.EXPO_PUBLIC_OPENAI_HEALTH_MODEL || process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
const token = await getAuthToken();
|
||||
return makeRequest<ServiceHealthResponse>('/health', {
|
||||
method: 'GET',
|
||||
token,
|
||||
});
|
||||
},
|
||||
|
||||
import { getAuthToken } from './userIdentityService';
|
||||
import { mockBackendService } from './mockBackendService';
|
||||
import { CareInfo, Language } from '../../types';
|
||||
|
||||
const BACKEND_BASE_URL = (process.env.EXPO_PUBLIC_BACKEND_URL || process.env.EXPO_PUBLIC_PAYMENT_SERVER_URL || '').trim();
|
||||
const REQUEST_TIMEOUT_MS = 15000;
|
||||
|
||||
const mapHttpStatusToErrorCode = (status: number): BackendErrorCode => {
|
||||
if (status === 400) return 'BAD_REQUEST';
|
||||
if (status === 401 || status === 403) return 'UNAUTHORIZED';
|
||||
if (status === 402) return 'INSUFFICIENT_CREDITS';
|
||||
if (status === 408 || status === 504) return 'TIMEOUT';
|
||||
return 'PROVIDER_ERROR';
|
||||
};
|
||||
|
||||
const buildBackendUrl = (path: string): string => {
|
||||
const base = BACKEND_BASE_URL.replace(/\/$/, '');
|
||||
return `${base}${path}`;
|
||||
};
|
||||
|
||||
const parseMaybeJson = (value: string): Record<string, unknown> | null => {
|
||||
if (!value) return null;
|
||||
try {
|
||||
return JSON.parse(value) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const makeRequest = async <T,>(
|
||||
path: string,
|
||||
options: {
|
||||
method: 'GET' | 'POST';
|
||||
token: string;
|
||||
idempotencyKey?: string;
|
||||
body?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<T> => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${options.token}`,
|
||||
};
|
||||
if (options.idempotencyKey) {
|
||||
headers['Idempotency-Key'] = options.idempotencyKey;
|
||||
}
|
||||
|
||||
const response = await fetch(buildBackendUrl(path), {
|
||||
method: options.method,
|
||||
headers,
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const rawText = await response.text();
|
||||
const jsonPayload = parseMaybeJson(rawText);
|
||||
|
||||
if (!response.ok) {
|
||||
const payloadCode = jsonPayload?.code;
|
||||
const code = typeof payloadCode === 'string'
|
||||
? payloadCode as BackendErrorCode
|
||||
: mapHttpStatusToErrorCode(response.status);
|
||||
const payloadMessage = jsonPayload?.message;
|
||||
const message = typeof payloadMessage === 'string'
|
||||
? payloadMessage
|
||||
: `Backend request failed with status ${response.status}.`;
|
||||
throw new BackendApiError(code, message, response.status, jsonPayload || undefined);
|
||||
}
|
||||
|
||||
if (jsonPayload == null) {
|
||||
throw new BackendApiError('PROVIDER_ERROR', 'Backend returned invalid JSON.', 502);
|
||||
}
|
||||
|
||||
return jsonPayload as T;
|
||||
} catch (error) {
|
||||
if (error instanceof BackendApiError) throw error;
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new BackendApiError('TIMEOUT', 'Backend request timed out.', 408);
|
||||
}
|
||||
throw new BackendApiError('NETWORK_ERROR', 'No network connection.', 0, {
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
|
||||
export const backendApiClient = {
|
||||
getServiceHealth: async (): Promise<ServiceHealthResponse> => {
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return {
|
||||
ok: true,
|
||||
uptimeSec: 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
openAiConfigured: Boolean(process.env.EXPO_PUBLIC_OPENAI_API_KEY),
|
||||
dbReady: true,
|
||||
dbPath: 'in-app-mock-backend',
|
||||
stripeConfigured: Boolean(process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY),
|
||||
scanModel: (process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim(),
|
||||
healthModel: (process.env.EXPO_PUBLIC_OPENAI_HEALTH_MODEL || process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
const token = await getAuthToken();
|
||||
return makeRequest<ServiceHealthResponse>('/health', {
|
||||
method: 'GET',
|
||||
token,
|
||||
});
|
||||
},
|
||||
|
||||
getBillingSummary: async (): Promise<BillingSummary> => {
|
||||
const token = await getAuthToken();
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return mockBackendService.getBillingSummary(token);
|
||||
}
|
||||
|
||||
const token = await getAuthToken();
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return mockBackendService.getBillingSummary(token);
|
||||
}
|
||||
|
||||
return makeRequest<BillingSummary>('/v1/billing/summary', {
|
||||
method: 'GET',
|
||||
token,
|
||||
});
|
||||
},
|
||||
|
||||
syncRevenueCatState: async (params: {
|
||||
customerInfo: RevenueCatCustomerInfo;
|
||||
}): Promise<SyncRevenueCatStateResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return mockBackendService.syncRevenueCatState({
|
||||
userId: token,
|
||||
customerInfo: params.customerInfo,
|
||||
});
|
||||
}
|
||||
|
||||
return makeRequest<SyncRevenueCatStateResponse>('/v1/billing/sync-revenuecat', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
customerInfo: params.customerInfo,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
scanPlant: async (params: {
|
||||
idempotencyKey: string;
|
||||
imageUri: string;
|
||||
language: Language;
|
||||
}): Promise<ScanPlantResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return mockBackendService.scanPlant({
|
||||
userId: token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
imageUri: params.imageUri,
|
||||
language: params.language,
|
||||
});
|
||||
}
|
||||
|
||||
return makeRequest<ScanPlantResponse>('/v1/scan', {
|
||||
method: 'POST',
|
||||
token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
body: {
|
||||
imageUri: params.imageUri,
|
||||
language: params.language,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
semanticSearch: async (params: {
|
||||
idempotencyKey: string;
|
||||
query: string;
|
||||
language: Language;
|
||||
}): Promise<SemanticSearchResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return mockBackendService.semanticSearch({
|
||||
userId: token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
query: params.query,
|
||||
language: params.language,
|
||||
});
|
||||
}
|
||||
|
||||
return makeRequest<SemanticSearchResponse>('/v1/search/semantic', {
|
||||
method: 'POST',
|
||||
token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
body: {
|
||||
query: params.query,
|
||||
language: params.language,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
runHealthCheck: async (params: {
|
||||
idempotencyKey: string;
|
||||
imageUri: string;
|
||||
language: Language;
|
||||
plantContext?: {
|
||||
name: string;
|
||||
botanicalName: string;
|
||||
careInfo: CareInfo;
|
||||
description?: string;
|
||||
};
|
||||
}): Promise<HealthCheckResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return mockBackendService.healthCheck({
|
||||
userId: token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
imageUri: params.imageUri,
|
||||
language: params.language,
|
||||
plantContext: params.plantContext,
|
||||
});
|
||||
}
|
||||
|
||||
return makeRequest<HealthCheckResponse>('/v1/health-check', {
|
||||
method: 'POST',
|
||||
token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
body: {
|
||||
imageUri: params.imageUri,
|
||||
language: params.language,
|
||||
plantContext: params.plantContext,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
simulatePurchase: async (params: {
|
||||
idempotencyKey: string;
|
||||
productId: PurchaseProductId;
|
||||
}): Promise<SimulatePurchaseResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return mockBackendService.simulatePurchase({
|
||||
userId: token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
productId: params.productId,
|
||||
});
|
||||
}
|
||||
|
||||
return makeRequest<SimulatePurchaseResponse>('/v1/billing/simulate-purchase', {
|
||||
method: 'POST',
|
||||
token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
body: {
|
||||
productId: params.productId,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
simulateWebhook: async (params: {
|
||||
idempotencyKey: string;
|
||||
event: SimulatedWebhookEvent;
|
||||
payload?: { credits?: number };
|
||||
}): Promise<SimulateWebhookResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return mockBackendService.simulateWebhook({
|
||||
userId: token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
event: params.event,
|
||||
payload: params.payload,
|
||||
});
|
||||
}
|
||||
|
||||
return makeRequest<SimulateWebhookResponse>('/v1/billing/simulate-webhook', {
|
||||
method: 'POST',
|
||||
token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
body: {
|
||||
event: params.event,
|
||||
payload: params.payload || {},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const isInsufficientCreditsError = (error: unknown): boolean => {
|
||||
return error instanceof BackendApiError && error.code === 'INSUFFICIENT_CREDITS';
|
||||
};
|
||||
idempotencyKey: string;
|
||||
imageUri: string;
|
||||
language: Language;
|
||||
}): Promise<ScanPlantResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return mockBackendService.scanPlant({
|
||||
userId: token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
imageUri: params.imageUri,
|
||||
language: params.language,
|
||||
});
|
||||
}
|
||||
|
||||
return makeRequest<ScanPlantResponse>('/v1/scan', {
|
||||
method: 'POST',
|
||||
token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
body: {
|
||||
imageUri: params.imageUri,
|
||||
language: params.language,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
semanticSearch: async (params: {
|
||||
idempotencyKey: string;
|
||||
query: string;
|
||||
language: Language;
|
||||
}): Promise<SemanticSearchResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return mockBackendService.semanticSearch({
|
||||
userId: token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
query: params.query,
|
||||
language: params.language,
|
||||
});
|
||||
}
|
||||
|
||||
return makeRequest<SemanticSearchResponse>('/v1/search/semantic', {
|
||||
method: 'POST',
|
||||
token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
body: {
|
||||
query: params.query,
|
||||
language: params.language,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
runHealthCheck: async (params: {
|
||||
idempotencyKey: string;
|
||||
imageUri: string;
|
||||
language: Language;
|
||||
plantContext?: {
|
||||
name: string;
|
||||
botanicalName: string;
|
||||
careInfo: CareInfo;
|
||||
description?: string;
|
||||
};
|
||||
}): Promise<HealthCheckResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return mockBackendService.healthCheck({
|
||||
userId: token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
imageUri: params.imageUri,
|
||||
language: params.language,
|
||||
plantContext: params.plantContext,
|
||||
});
|
||||
}
|
||||
|
||||
return makeRequest<HealthCheckResponse>('/v1/health-check', {
|
||||
method: 'POST',
|
||||
token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
body: {
|
||||
imageUri: params.imageUri,
|
||||
language: params.language,
|
||||
plantContext: params.plantContext,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
simulatePurchase: async (params: {
|
||||
idempotencyKey: string;
|
||||
productId: PurchaseProductId;
|
||||
}): Promise<SimulatePurchaseResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return mockBackendService.simulatePurchase({
|
||||
userId: token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
productId: params.productId,
|
||||
});
|
||||
}
|
||||
|
||||
return makeRequest<SimulatePurchaseResponse>('/v1/billing/simulate-purchase', {
|
||||
method: 'POST',
|
||||
token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
body: {
|
||||
productId: params.productId,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
simulateWebhook: async (params: {
|
||||
idempotencyKey: string;
|
||||
event: SimulatedWebhookEvent;
|
||||
payload?: { credits?: number };
|
||||
}): Promise<SimulateWebhookResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return mockBackendService.simulateWebhook({
|
||||
userId: token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
event: params.event,
|
||||
payload: params.payload,
|
||||
});
|
||||
}
|
||||
|
||||
return makeRequest<SimulateWebhookResponse>('/v1/billing/simulate-webhook', {
|
||||
method: 'POST',
|
||||
token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
body: {
|
||||
event: params.event,
|
||||
payload: params.payload || {},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const isInsufficientCreditsError = (error: unknown): boolean => {
|
||||
return error instanceof BackendApiError && error.code === 'INSUFFICIENT_CREDITS';
|
||||
};
|
||||
|
||||
export const isNetworkError = (error: unknown): boolean => {
|
||||
return (
|
||||
error instanceof BackendApiError &&
|
||||
(error.code === 'NETWORK_ERROR' || error.code === 'TIMEOUT')
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,156 +1,186 @@
|
||||
import { CareInfo, IdentificationResult, Language, PlantHealthCheck } from '../../types';
|
||||
|
||||
export type PlanId = 'free' | 'pro';
|
||||
export type BillingProvider = 'mock' | 'revenuecat' | 'stripe';
|
||||
export type PurchaseProductId = 'monthly_pro' | 'yearly_pro' | 'topup_small' | 'topup_medium' | 'topup_large';
|
||||
export type SimulatedWebhookEvent =
|
||||
| 'entitlement_granted'
|
||||
| 'entitlement_revoked'
|
||||
| 'topup_granted'
|
||||
| 'credits_depleted';
|
||||
|
||||
export interface BackendDatabaseEntry extends IdentificationResult {
|
||||
imageUri: string;
|
||||
imageStatus?: 'ok' | 'missing' | 'invalid';
|
||||
categories: string[];
|
||||
}
|
||||
|
||||
export interface CreditState {
|
||||
monthlyAllowance: number;
|
||||
usedThisCycle: number;
|
||||
topupBalance: number;
|
||||
available: number;
|
||||
cycleStartedAt: string;
|
||||
cycleEndsAt: string;
|
||||
}
|
||||
|
||||
export interface EntitlementState {
|
||||
plan: PlanId;
|
||||
provider: BillingProvider;
|
||||
status: 'active' | 'inactive';
|
||||
renewsAt: string | null;
|
||||
}
|
||||
|
||||
import { CareInfo, IdentificationResult, Language, PlantHealthCheck } from '../../types';
|
||||
|
||||
export type PlanId = 'free' | 'pro';
|
||||
export type BillingProvider = 'mock' | 'revenuecat' | 'stripe';
|
||||
export type PurchaseProductId = 'monthly_pro' | 'yearly_pro' | 'topup_small' | 'topup_medium' | 'topup_large';
|
||||
export type SimulatedWebhookEvent =
|
||||
| 'entitlement_granted'
|
||||
| 'entitlement_revoked'
|
||||
| 'topup_granted'
|
||||
| 'credits_depleted';
|
||||
|
||||
export interface BackendDatabaseEntry extends IdentificationResult {
|
||||
imageUri: string;
|
||||
imageStatus?: 'ok' | 'missing' | 'invalid';
|
||||
categories: string[];
|
||||
}
|
||||
|
||||
export interface CreditState {
|
||||
monthlyAllowance: number;
|
||||
usedThisCycle: number;
|
||||
topupBalance: number;
|
||||
available: number;
|
||||
cycleStartedAt: string;
|
||||
cycleEndsAt: string;
|
||||
}
|
||||
|
||||
export interface EntitlementState {
|
||||
plan: PlanId;
|
||||
provider: BillingProvider;
|
||||
status: 'active' | 'inactive';
|
||||
renewsAt: string | null;
|
||||
}
|
||||
|
||||
export interface BillingSummary {
|
||||
entitlement: EntitlementState;
|
||||
credits: CreditState;
|
||||
availableProducts: PurchaseProductId[];
|
||||
}
|
||||
|
||||
export interface ScanPlantRequest {
|
||||
userId: string;
|
||||
idempotencyKey: string;
|
||||
imageUri: string;
|
||||
language: Language;
|
||||
export interface RevenueCatEntitlementInfo {
|
||||
productIdentifier?: string;
|
||||
expirationDate?: string | null;
|
||||
expiresDate?: string | null;
|
||||
}
|
||||
|
||||
export interface ScanPlantResponse {
|
||||
result: IdentificationResult;
|
||||
creditsCharged: number;
|
||||
modelPath: string[];
|
||||
modelUsed?: string | null;
|
||||
modelFallbackCount?: number;
|
||||
billing: BillingSummary;
|
||||
export interface RevenueCatNonSubscriptionTransaction {
|
||||
productIdentifier?: string;
|
||||
transactionIdentifier?: string;
|
||||
transactionId?: string;
|
||||
purchaseDate?: string | null;
|
||||
}
|
||||
|
||||
export interface SemanticSearchRequest {
|
||||
userId: string;
|
||||
idempotencyKey: string;
|
||||
query: string;
|
||||
language: Language;
|
||||
}
|
||||
|
||||
export interface SemanticSearchResponse {
|
||||
status: 'success' | 'no_results';
|
||||
results: BackendDatabaseEntry[];
|
||||
creditsCharged: number;
|
||||
billing: BillingSummary;
|
||||
}
|
||||
|
||||
export interface HealthCheckRequest {
|
||||
userId: string;
|
||||
idempotencyKey: string;
|
||||
imageUri: string;
|
||||
language: Language;
|
||||
plantContext?: {
|
||||
name: string;
|
||||
botanicalName: string;
|
||||
careInfo: CareInfo;
|
||||
description?: string;
|
||||
export interface RevenueCatCustomerInfo {
|
||||
appUserId?: string | null;
|
||||
originalAppUserId?: string | null;
|
||||
entitlements: {
|
||||
active: Record<string, RevenueCatEntitlementInfo>;
|
||||
};
|
||||
nonSubscriptions?: Record<string, RevenueCatNonSubscriptionTransaction[]>;
|
||||
allPurchasedProductIdentifiers?: string[];
|
||||
latestExpirationDate?: string | null;
|
||||
}
|
||||
|
||||
export interface HealthCheckResponse {
|
||||
healthCheck: PlantHealthCheck;
|
||||
creditsCharged: number;
|
||||
modelUsed?: string | null;
|
||||
modelFallbackCount?: number;
|
||||
billing: BillingSummary;
|
||||
}
|
||||
|
||||
export interface ServiceHealthResponse {
|
||||
ok: boolean;
|
||||
uptimeSec: number;
|
||||
timestamp: string;
|
||||
openAiConfigured: boolean;
|
||||
dbReady?: boolean;
|
||||
dbPath?: string;
|
||||
stripeConfigured?: boolean;
|
||||
scanModel?: string;
|
||||
healthModel?: string;
|
||||
}
|
||||
|
||||
export interface SimulatePurchaseRequest {
|
||||
userId: string;
|
||||
idempotencyKey: string;
|
||||
productId: PurchaseProductId;
|
||||
}
|
||||
|
||||
export interface SimulatePurchaseResponse {
|
||||
appliedProduct: PurchaseProductId;
|
||||
billing: BillingSummary;
|
||||
}
|
||||
|
||||
export interface SimulateWebhookRequest {
|
||||
userId: string;
|
||||
idempotencyKey: string;
|
||||
event: SimulatedWebhookEvent;
|
||||
payload?: {
|
||||
credits?: number;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export interface ScanPlantRequest {
|
||||
userId: string;
|
||||
idempotencyKey: string;
|
||||
imageUri: string;
|
||||
language: Language;
|
||||
}
|
||||
|
||||
export interface ScanPlantResponse {
|
||||
result: IdentificationResult;
|
||||
creditsCharged: number;
|
||||
modelPath: string[];
|
||||
modelUsed?: string | null;
|
||||
modelFallbackCount?: number;
|
||||
billing: BillingSummary;
|
||||
}
|
||||
|
||||
export interface SemanticSearchRequest {
|
||||
userId: string;
|
||||
idempotencyKey: string;
|
||||
query: string;
|
||||
language: Language;
|
||||
}
|
||||
|
||||
export interface SemanticSearchResponse {
|
||||
status: 'success' | 'no_results';
|
||||
results: BackendDatabaseEntry[];
|
||||
creditsCharged: number;
|
||||
billing: BillingSummary;
|
||||
}
|
||||
|
||||
export interface HealthCheckRequest {
|
||||
userId: string;
|
||||
idempotencyKey: string;
|
||||
imageUri: string;
|
||||
language: Language;
|
||||
plantContext?: {
|
||||
name: string;
|
||||
botanicalName: string;
|
||||
careInfo: CareInfo;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface HealthCheckResponse {
|
||||
healthCheck: PlantHealthCheck;
|
||||
creditsCharged: number;
|
||||
modelUsed?: string | null;
|
||||
modelFallbackCount?: number;
|
||||
billing: BillingSummary;
|
||||
}
|
||||
|
||||
export interface ServiceHealthResponse {
|
||||
ok: boolean;
|
||||
uptimeSec: number;
|
||||
timestamp: string;
|
||||
openAiConfigured: boolean;
|
||||
dbReady?: boolean;
|
||||
dbPath?: string;
|
||||
stripeConfigured?: boolean;
|
||||
scanModel?: string;
|
||||
healthModel?: string;
|
||||
}
|
||||
|
||||
export interface SimulatePurchaseRequest {
|
||||
userId: string;
|
||||
idempotencyKey: string;
|
||||
productId: PurchaseProductId;
|
||||
}
|
||||
|
||||
export interface SimulatePurchaseResponse {
|
||||
appliedProduct: PurchaseProductId;
|
||||
billing: BillingSummary;
|
||||
}
|
||||
|
||||
export interface SimulateWebhookRequest {
|
||||
userId: string;
|
||||
idempotencyKey: string;
|
||||
event: SimulatedWebhookEvent;
|
||||
payload?: {
|
||||
credits?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SimulateWebhookResponse {
|
||||
event: SimulatedWebhookEvent;
|
||||
billing: BillingSummary;
|
||||
}
|
||||
|
||||
export type BackendErrorCode =
|
||||
| 'INSUFFICIENT_CREDITS'
|
||||
| 'UNAUTHORIZED'
|
||||
| 'TIMEOUT'
|
||||
| 'PROVIDER_ERROR'
|
||||
| 'BAD_REQUEST';
|
||||
|
||||
export class BackendApiError extends Error {
|
||||
public readonly code: BackendErrorCode;
|
||||
public readonly status: number;
|
||||
public readonly metadata?: Record<string, unknown>;
|
||||
|
||||
constructor(
|
||||
code: BackendErrorCode,
|
||||
message: string,
|
||||
status = 500,
|
||||
metadata?: Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'BackendApiError';
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
this.metadata = metadata;
|
||||
}
|
||||
export interface SyncRevenueCatStateResponse {
|
||||
billing: BillingSummary;
|
||||
syncedAt: string;
|
||||
}
|
||||
|
||||
export const isBackendApiError = (error: unknown): error is BackendApiError => {
|
||||
return error instanceof BackendApiError;
|
||||
};
|
||||
|
||||
export type BackendErrorCode =
|
||||
| 'INSUFFICIENT_CREDITS'
|
||||
| 'UNAUTHORIZED'
|
||||
| 'TIMEOUT'
|
||||
| 'NETWORK_ERROR'
|
||||
| 'PROVIDER_ERROR'
|
||||
| 'BAD_REQUEST';
|
||||
|
||||
export class BackendApiError extends Error {
|
||||
public readonly code: BackendErrorCode;
|
||||
public readonly status: number;
|
||||
public readonly metadata?: Record<string, unknown>;
|
||||
|
||||
constructor(
|
||||
code: BackendErrorCode,
|
||||
message: string,
|
||||
status = 500,
|
||||
metadata?: Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'BackendApiError';
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
this.metadata = metadata;
|
||||
}
|
||||
}
|
||||
|
||||
export const isBackendApiError = (error: unknown): error is BackendApiError => {
|
||||
return error instanceof BackendApiError;
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,299 +1,299 @@
|
||||
import { Language } from '../../types';
|
||||
import { BackendDatabaseEntry } from './contracts';
|
||||
import { LEXICON_BATCH_1_ENTRIES } from '../../constants/lexiconBatch1';
|
||||
import { LEXICON_BATCH_2_ENTRIES } from '../../constants/lexiconBatch2';
|
||||
import { normalizeSearchText, rankHybridEntries } from '../../utils/hybridSearch';
|
||||
|
||||
interface MockPlantSeed {
|
||||
botanicalName: string;
|
||||
names: Record<Language, string>;
|
||||
descriptions: Record<Language, string>;
|
||||
imageUri: string;
|
||||
categories: string[];
|
||||
baseConfidence: number;
|
||||
care: {
|
||||
waterIntervalDays: number;
|
||||
light: Record<Language, string>;
|
||||
temp: string;
|
||||
};
|
||||
}
|
||||
|
||||
const PLANTS: MockPlantSeed[] = [
|
||||
{
|
||||
botanicalName: 'Monstera deliciosa',
|
||||
names: { de: 'Monstera', en: 'Monstera', es: 'Monstera' },
|
||||
descriptions: {
|
||||
de: 'Beliebte Zimmerpflanze mit grossen gelochten Blaettern. Mag helles indirektes Licht.',
|
||||
en: 'Popular indoor plant with large split leaves. Prefers bright indirect light.',
|
||||
es: 'Planta de interior popular con hojas grandes. Prefiere luz brillante indirecta.',
|
||||
},
|
||||
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/2/2e/Monstera_deliciosa2.jpg/500px-Monstera_deliciosa2.jpg',
|
||||
categories: ['easy', 'low_light', 'air_purifier'],
|
||||
baseConfidence: 0.76,
|
||||
care: {
|
||||
waterIntervalDays: 7,
|
||||
light: { de: 'Halbschatten', en: 'Partial shade', es: 'Sombra parcial' },
|
||||
temp: '18-24C',
|
||||
},
|
||||
},
|
||||
{
|
||||
botanicalName: 'Sansevieria trifasciata',
|
||||
names: { de: 'Bogenhanf', en: 'Snake Plant', es: 'Lengua de suegra' },
|
||||
descriptions: {
|
||||
de: 'Sehr robust und trockenheitsvertraeglich. Kommt auch mit wenig Licht klar.',
|
||||
en: 'Very resilient and drought tolerant. Handles low light well.',
|
||||
es: 'Muy resistente y tolera sequia. Funciona bien con poca luz.',
|
||||
},
|
||||
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/fb/Snake_Plant_%28Sansevieria_trifasciata_%27Laurentii%27%29.jpg/500px-Snake_Plant_%28Sansevieria_trifasciata_%27Laurentii%27%29.jpg',
|
||||
categories: ['easy', 'succulent', 'low_light'],
|
||||
baseConfidence: 0.73,
|
||||
care: {
|
||||
waterIntervalDays: 14,
|
||||
light: { de: 'Schatten bis Sonne', en: 'Shade to sun', es: 'Sombra a sol' },
|
||||
temp: '16-30C',
|
||||
},
|
||||
},
|
||||
{
|
||||
botanicalName: 'Aloe vera',
|
||||
names: { de: 'Aloe Vera', en: 'Aloe Vera', es: 'Aloe Vera' },
|
||||
descriptions: {
|
||||
de: 'Sukkulente mit heilender Gelstruktur in den Blaettern. Braucht viel Licht.',
|
||||
en: 'Succulent with gel-filled leaves. Needs plenty of light.',
|
||||
es: 'Suculenta con hojas de gel. Necesita bastante luz.',
|
||||
},
|
||||
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/4b/Aloe_vera_flower_inset.png/500px-Aloe_vera_flower_inset.png',
|
||||
categories: ['succulent', 'easy', 'sun'],
|
||||
baseConfidence: 0.78,
|
||||
care: {
|
||||
waterIntervalDays: 12,
|
||||
light: { de: 'Sonnig', en: 'Sunny', es: 'Soleado' },
|
||||
temp: '18-30C',
|
||||
},
|
||||
},
|
||||
{
|
||||
botanicalName: 'Epipremnum aureum',
|
||||
names: { de: 'Efeutute', en: 'Pothos', es: 'Poto' },
|
||||
descriptions: {
|
||||
de: 'Schnell wachsende Haengepflanze fuer Einsteiger. Vertraegt variierende Bedingungen.',
|
||||
en: 'Fast-growing trailing plant ideal for beginners. Handles varied conditions.',
|
||||
es: 'Planta colgante de crecimiento rapido para principiantes.',
|
||||
},
|
||||
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/62/Money_Plant_%28Epipremnum_aureum%29_4.jpg/500px-Money_Plant_%28Epipremnum_aureum%29_4.jpg',
|
||||
categories: ['easy', 'hanging', 'air_purifier'],
|
||||
baseConfidence: 0.74,
|
||||
care: {
|
||||
waterIntervalDays: 7,
|
||||
light: { de: 'Halbschatten bis hell', en: 'Partial shade to bright', es: 'Sombra parcial a brillante' },
|
||||
temp: '18-27C',
|
||||
},
|
||||
},
|
||||
{
|
||||
botanicalName: 'Spathiphyllum',
|
||||
names: { de: 'Einblatt', en: 'Peace Lily', es: 'Cuna de moises' },
|
||||
descriptions: {
|
||||
de: 'Bluetenpflanze fuer Innenraeume. Zeigt Wasserbedarf schnell durch haengende Blaetter.',
|
||||
en: 'Indoor flowering plant. Droops clearly when it needs water.',
|
||||
es: 'Planta de flor de interior. Muestra rapido cuando necesita riego.',
|
||||
},
|
||||
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/b/bd/Spathiphyllum_cochlearispathum_RTBG.jpg/500px-Spathiphyllum_cochlearispathum_RTBG.jpg',
|
||||
categories: ['flowering', 'low_light'],
|
||||
baseConfidence: 0.72,
|
||||
care: {
|
||||
waterIntervalDays: 5,
|
||||
light: { de: 'Halbschatten', en: 'Partial shade', es: 'Sombra parcial' },
|
||||
temp: '18-26C',
|
||||
},
|
||||
},
|
||||
{
|
||||
botanicalName: 'Zamioculcas zamiifolia',
|
||||
names: { de: 'Gluecksfeder', en: 'ZZ Plant', es: 'Planta ZZ' },
|
||||
descriptions: {
|
||||
de: 'Extrem pflegeleichte Pflanze mit dickem Rhizom. Perfekt fuer wenig Zeit.',
|
||||
en: 'Extremely low-maintenance plant with thick rhizomes. Great for busy schedules.',
|
||||
es: 'Planta muy facil de cuidar con rizomas gruesos.',
|
||||
},
|
||||
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/c/cf/Zamioculcas_zamiifolia_1.jpg/500px-Zamioculcas_zamiifolia_1.jpg',
|
||||
categories: ['easy', 'low_light'],
|
||||
baseConfidence: 0.77,
|
||||
care: {
|
||||
waterIntervalDays: 14,
|
||||
light: { de: 'Wenig Licht bis hell', en: 'Low light to bright', es: 'Poca luz a brillante' },
|
||||
temp: '18-28C',
|
||||
},
|
||||
},
|
||||
{
|
||||
botanicalName: 'Calathea orbifolia',
|
||||
names: { de: 'Korbmarante', en: 'Calathea', es: 'Calathea' },
|
||||
descriptions: {
|
||||
de: 'Dekorative Blaetter mit Muster. Liebt gleichmaessige Feuchte und hohe Luftfeuchtigkeit.',
|
||||
en: 'Decorative patterned leaves. Prefers steady moisture and humidity.',
|
||||
es: 'Hojas decorativas con patron. Prefiere humedad constante.',
|
||||
},
|
||||
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/fa/Calathea_orbifolia_2.jpg/500px-Calathea_orbifolia_2.jpg',
|
||||
categories: ['patterned', 'pet_friendly'],
|
||||
baseConfidence: 0.68,
|
||||
care: {
|
||||
waterIntervalDays: 4,
|
||||
light: { de: 'Halbschatten', en: 'Partial shade', es: 'Sombra parcial' },
|
||||
temp: '18-25C',
|
||||
},
|
||||
},
|
||||
{
|
||||
botanicalName: 'Chlorophytum comosum',
|
||||
names: { de: 'Gruenlilie', en: 'Spider Plant', es: 'Cinta' },
|
||||
descriptions: {
|
||||
de: 'Unkomplizierte Zimmerpflanze mit langen gebogenen Blaettern. Vermehrt sich schnell.',
|
||||
en: 'Easy houseplant with long arched leaves. Propagates quickly.',
|
||||
es: 'Planta sencilla con hojas largas arqueadas. Se multiplica rapido.',
|
||||
},
|
||||
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/b/b1/Hierbabuena_0611_Revised.jpg/500px-Hierbabuena_0611_Revised.jpg',
|
||||
categories: ['easy', 'pet_friendly', 'air_purifier'],
|
||||
baseConfidence: 0.79,
|
||||
care: {
|
||||
waterIntervalDays: 6,
|
||||
light: { de: 'Hell bis halbschattig', en: 'Bright to partial shade', es: 'Brillante a sombra parcial' },
|
||||
temp: '16-24C',
|
||||
},
|
||||
},
|
||||
{
|
||||
botanicalName: 'Anthurium andraeanum',
|
||||
names: { de: 'Flamingoblume', en: 'Anthurium', es: 'Anturio' },
|
||||
descriptions: {
|
||||
de: 'Auffaellige Hochblaetter und lange Bluetezeit. Braucht Waerme und gleichmaessige Feuchte.',
|
||||
en: 'Known for vivid spathes and long bloom cycles. Likes warmth and even moisture.',
|
||||
es: 'Conocida por sus flores vistosas. Requiere calor y riego regular.',
|
||||
},
|
||||
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/8b/AnthuriumAndraenum.jpg/500px-AnthuriumAndraenum.jpg',
|
||||
categories: ['flowering', 'high_humidity'],
|
||||
baseConfidence: 0.71,
|
||||
care: {
|
||||
waterIntervalDays: 6,
|
||||
light: { de: 'Hell ohne direkte Sonne', en: 'Bright indirect light', es: 'Luz brillante indirecta' },
|
||||
temp: '18-27C',
|
||||
},
|
||||
},
|
||||
{
|
||||
botanicalName: 'Nephrolepis exaltata',
|
||||
names: { de: 'Schwertfarn', en: 'Boston Fern', es: 'Helecho de Boston' },
|
||||
descriptions: {
|
||||
de: 'Fein gefiederter Farn. Benoetigt regelmaessige Feuchte und keine direkte Sonne.',
|
||||
en: 'Fine-textured fern. Needs regular moisture and no harsh direct sun.',
|
||||
es: 'Helecho de textura fina. Necesita humedad constante.',
|
||||
},
|
||||
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/3/30/Boston_Fern_%282873392811%29.png/500px-Boston_Fern_%282873392811%29.png',
|
||||
categories: ['pet_friendly', 'air_purifier', 'high_humidity'],
|
||||
baseConfidence: 0.7,
|
||||
care: {
|
||||
waterIntervalDays: 3,
|
||||
light: { de: 'Halbschatten', en: 'Partial shade', es: 'Sombra parcial' },
|
||||
temp: '16-24C',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const hashString = (value: string): number => {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
hash ^= value.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return hash >>> 0;
|
||||
};
|
||||
|
||||
const clampConfidence = (value: number): number => {
|
||||
return Number(Math.max(0.4, Math.min(0.99, value)).toFixed(2));
|
||||
};
|
||||
|
||||
const toEntry = (
|
||||
seed: MockPlantSeed,
|
||||
lang: Language,
|
||||
confidence: number,
|
||||
): BackendDatabaseEntry => {
|
||||
return {
|
||||
name: seed.names[lang],
|
||||
botanicalName: seed.botanicalName,
|
||||
description: seed.descriptions[lang],
|
||||
confidence: clampConfidence(confidence),
|
||||
careInfo: {
|
||||
waterIntervalDays: seed.care.waterIntervalDays,
|
||||
light: seed.care.light[lang],
|
||||
temp: seed.care.temp,
|
||||
},
|
||||
imageUri: seed.imageUri,
|
||||
categories: seed.categories,
|
||||
};
|
||||
};
|
||||
|
||||
const getFullCatalog = (lang: Language): BackendDatabaseEntry[] => {
|
||||
const seeds = PLANTS.map(seed => toEntry(seed, lang, seed.baseConfidence));
|
||||
const batches = getBatchCatalog(lang);
|
||||
const catalog = [...seeds, ...batches];
|
||||
|
||||
const seenBotanical = new Set<string>();
|
||||
const seenImage = new Set<string>();
|
||||
|
||||
return catalog.filter(e => {
|
||||
const botanicalKey = normalizeSearchText(e.botanicalName);
|
||||
const imageKey = e.imageUri.trim();
|
||||
|
||||
if (seenBotanical.has(botanicalKey) || seenImage.has(imageKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seenBotanical.add(botanicalKey);
|
||||
seenImage.add(imageKey);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
export const getMockCatalog = (lang: Language): BackendDatabaseEntry[] => {
|
||||
return getFullCatalog(lang);
|
||||
};
|
||||
|
||||
export const getMockPlantByImage = (
|
||||
imageUri: string,
|
||||
lang: Language,
|
||||
preferHighConfidence: boolean,
|
||||
): BackendDatabaseEntry => {
|
||||
const seeds = PLANTS;
|
||||
const indexHash = hashString(`${lang}:${imageUri}`);
|
||||
const selectedSeed = seeds[indexHash % seeds.length];
|
||||
|
||||
const varianceHash = hashString(`confidence:${imageUri}:${selectedSeed.botanicalName}`);
|
||||
const variance = (varianceHash % 28) / 100; // 0.00 - 0.27
|
||||
const confidenceBase = selectedSeed.baseConfidence - 0.08 + variance;
|
||||
const confidence = preferHighConfidence ? confidenceBase + 0.09 : confidenceBase;
|
||||
|
||||
return toEntry(selectedSeed, lang, confidence);
|
||||
};
|
||||
|
||||
const getBatchCatalog = (lang: Language): BackendDatabaseEntry[] => {
|
||||
const all = [...LEXICON_BATCH_1_ENTRIES, ...LEXICON_BATCH_2_ENTRIES];
|
||||
return all.map(entry => ({
|
||||
name: entry.name,
|
||||
botanicalName: entry.botanicalName,
|
||||
description: entry.description || '',
|
||||
confidence: entry.confidence,
|
||||
careInfo: {
|
||||
waterIntervalDays: entry.careInfo.waterIntervalDays,
|
||||
light: entry.careInfo.light,
|
||||
temp: entry.careInfo.temp,
|
||||
},
|
||||
imageUri: entry.imageUri,
|
||||
categories: entry.categories,
|
||||
}));
|
||||
};
|
||||
|
||||
export const searchMockCatalog = (
|
||||
query: string,
|
||||
lang: Language,
|
||||
limit = 12,
|
||||
): BackendDatabaseEntry[] => {
|
||||
const normalizedQuery = normalizeSearchText(query);
|
||||
const deduped = getFullCatalog(lang);
|
||||
|
||||
if (!normalizedQuery) return deduped.slice(0, limit);
|
||||
|
||||
return rankHybridEntries(deduped, normalizedQuery, limit)
|
||||
.map((candidate) => candidate.entry);
|
||||
};
|
||||
import { Language } from '../../types';
|
||||
import { BackendDatabaseEntry } from './contracts';
|
||||
import { LEXICON_BATCH_1_ENTRIES } from '../../constants/lexiconBatch1';
|
||||
import { LEXICON_BATCH_2_ENTRIES } from '../../constants/lexiconBatch2';
|
||||
import { normalizeSearchText, rankHybridEntries } from '../../utils/hybridSearch';
|
||||
|
||||
interface MockPlantSeed {
|
||||
botanicalName: string;
|
||||
names: Record<Language, string>;
|
||||
descriptions: Record<Language, string>;
|
||||
imageUri: string;
|
||||
categories: string[];
|
||||
baseConfidence: number;
|
||||
care: {
|
||||
waterIntervalDays: number;
|
||||
light: Record<Language, string>;
|
||||
temp: string;
|
||||
};
|
||||
}
|
||||
|
||||
const PLANTS: MockPlantSeed[] = [
|
||||
{
|
||||
botanicalName: 'Monstera deliciosa',
|
||||
names: { de: 'Monstera', en: 'Monstera', es: 'Monstera' },
|
||||
descriptions: {
|
||||
de: 'Beliebte Zimmerpflanze mit grossen gelochten Blaettern. Mag helles indirektes Licht.',
|
||||
en: 'Popular indoor plant with large split leaves. Prefers bright indirect light.',
|
||||
es: 'Planta de interior popular con hojas grandes. Prefiere luz brillante indirecta.',
|
||||
},
|
||||
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/2/2e/Monstera_deliciosa2.jpg/500px-Monstera_deliciosa2.jpg',
|
||||
categories: ['easy', 'low_light', 'air_purifier'],
|
||||
baseConfidence: 0.76,
|
||||
care: {
|
||||
waterIntervalDays: 7,
|
||||
light: { de: 'Halbschatten', en: 'Partial shade', es: 'Sombra parcial' },
|
||||
temp: '18-24C',
|
||||
},
|
||||
},
|
||||
{
|
||||
botanicalName: 'Sansevieria trifasciata',
|
||||
names: { de: 'Bogenhanf', en: 'Snake Plant', es: 'Lengua de suegra' },
|
||||
descriptions: {
|
||||
de: 'Sehr robust und trockenheitsvertraeglich. Kommt auch mit wenig Licht klar.',
|
||||
en: 'Very resilient and drought tolerant. Handles low light well.',
|
||||
es: 'Muy resistente y tolera sequia. Funciona bien con poca luz.',
|
||||
},
|
||||
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/fb/Snake_Plant_%28Sansevieria_trifasciata_%27Laurentii%27%29.jpg/500px-Snake_Plant_%28Sansevieria_trifasciata_%27Laurentii%27%29.jpg',
|
||||
categories: ['easy', 'succulent', 'low_light'],
|
||||
baseConfidence: 0.73,
|
||||
care: {
|
||||
waterIntervalDays: 14,
|
||||
light: { de: 'Schatten bis Sonne', en: 'Shade to sun', es: 'Sombra a sol' },
|
||||
temp: '16-30C',
|
||||
},
|
||||
},
|
||||
{
|
||||
botanicalName: 'Aloe vera',
|
||||
names: { de: 'Aloe Vera', en: 'Aloe Vera', es: 'Aloe Vera' },
|
||||
descriptions: {
|
||||
de: 'Sukkulente mit heilender Gelstruktur in den Blaettern. Braucht viel Licht.',
|
||||
en: 'Succulent with gel-filled leaves. Needs plenty of light.',
|
||||
es: 'Suculenta con hojas de gel. Necesita bastante luz.',
|
||||
},
|
||||
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/4b/Aloe_vera_flower_inset.png/500px-Aloe_vera_flower_inset.png',
|
||||
categories: ['succulent', 'easy', 'sun'],
|
||||
baseConfidence: 0.78,
|
||||
care: {
|
||||
waterIntervalDays: 12,
|
||||
light: { de: 'Sonnig', en: 'Sunny', es: 'Soleado' },
|
||||
temp: '18-30C',
|
||||
},
|
||||
},
|
||||
{
|
||||
botanicalName: 'Epipremnum aureum',
|
||||
names: { de: 'Efeutute', en: 'Pothos', es: 'Poto' },
|
||||
descriptions: {
|
||||
de: 'Schnell wachsende Haengepflanze fuer Einsteiger. Vertraegt variierende Bedingungen.',
|
||||
en: 'Fast-growing trailing plant ideal for beginners. Handles varied conditions.',
|
||||
es: 'Planta colgante de crecimiento rapido para principiantes.',
|
||||
},
|
||||
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/62/Money_Plant_%28Epipremnum_aureum%29_4.jpg/500px-Money_Plant_%28Epipremnum_aureum%29_4.jpg',
|
||||
categories: ['easy', 'hanging', 'air_purifier'],
|
||||
baseConfidence: 0.74,
|
||||
care: {
|
||||
waterIntervalDays: 7,
|
||||
light: { de: 'Halbschatten bis hell', en: 'Partial shade to bright', es: 'Sombra parcial a brillante' },
|
||||
temp: '18-27C',
|
||||
},
|
||||
},
|
||||
{
|
||||
botanicalName: 'Spathiphyllum',
|
||||
names: { de: 'Einblatt', en: 'Peace Lily', es: 'Cuna de moises' },
|
||||
descriptions: {
|
||||
de: 'Bluetenpflanze fuer Innenraeume. Zeigt Wasserbedarf schnell durch haengende Blaetter.',
|
||||
en: 'Indoor flowering plant. Droops clearly when it needs water.',
|
||||
es: 'Planta de flor de interior. Muestra rapido cuando necesita riego.',
|
||||
},
|
||||
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/b/bd/Spathiphyllum_cochlearispathum_RTBG.jpg/500px-Spathiphyllum_cochlearispathum_RTBG.jpg',
|
||||
categories: ['flowering', 'low_light'],
|
||||
baseConfidence: 0.72,
|
||||
care: {
|
||||
waterIntervalDays: 5,
|
||||
light: { de: 'Halbschatten', en: 'Partial shade', es: 'Sombra parcial' },
|
||||
temp: '18-26C',
|
||||
},
|
||||
},
|
||||
{
|
||||
botanicalName: 'Zamioculcas zamiifolia',
|
||||
names: { de: 'Gluecksfeder', en: 'ZZ Plant', es: 'Planta ZZ' },
|
||||
descriptions: {
|
||||
de: 'Extrem pflegeleichte Pflanze mit dickem Rhizom. Perfekt fuer wenig Zeit.',
|
||||
en: 'Extremely low-maintenance plant with thick rhizomes. Great for busy schedules.',
|
||||
es: 'Planta muy facil de cuidar con rizomas gruesos.',
|
||||
},
|
||||
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/c/cf/Zamioculcas_zamiifolia_1.jpg/500px-Zamioculcas_zamiifolia_1.jpg',
|
||||
categories: ['easy', 'low_light'],
|
||||
baseConfidence: 0.77,
|
||||
care: {
|
||||
waterIntervalDays: 14,
|
||||
light: { de: 'Wenig Licht bis hell', en: 'Low light to bright', es: 'Poca luz a brillante' },
|
||||
temp: '18-28C',
|
||||
},
|
||||
},
|
||||
{
|
||||
botanicalName: 'Calathea orbifolia',
|
||||
names: { de: 'Korbmarante', en: 'Calathea', es: 'Calathea' },
|
||||
descriptions: {
|
||||
de: 'Dekorative Blaetter mit Muster. Liebt gleichmaessige Feuchte und hohe Luftfeuchtigkeit.',
|
||||
en: 'Decorative patterned leaves. Prefers steady moisture and humidity.',
|
||||
es: 'Hojas decorativas con patron. Prefiere humedad constante.',
|
||||
},
|
||||
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/fa/Calathea_orbifolia_2.jpg/500px-Calathea_orbifolia_2.jpg',
|
||||
categories: ['patterned', 'pet_friendly'],
|
||||
baseConfidence: 0.68,
|
||||
care: {
|
||||
waterIntervalDays: 4,
|
||||
light: { de: 'Halbschatten', en: 'Partial shade', es: 'Sombra parcial' },
|
||||
temp: '18-25C',
|
||||
},
|
||||
},
|
||||
{
|
||||
botanicalName: 'Chlorophytum comosum',
|
||||
names: { de: 'Gruenlilie', en: 'Spider Plant', es: 'Cinta' },
|
||||
descriptions: {
|
||||
de: 'Unkomplizierte Zimmerpflanze mit langen gebogenen Blaettern. Vermehrt sich schnell.',
|
||||
en: 'Easy houseplant with long arched leaves. Propagates quickly.',
|
||||
es: 'Planta sencilla con hojas largas arqueadas. Se multiplica rapido.',
|
||||
},
|
||||
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/b/b1/Hierbabuena_0611_Revised.jpg/500px-Hierbabuena_0611_Revised.jpg',
|
||||
categories: ['easy', 'pet_friendly', 'air_purifier'],
|
||||
baseConfidence: 0.79,
|
||||
care: {
|
||||
waterIntervalDays: 6,
|
||||
light: { de: 'Hell bis halbschattig', en: 'Bright to partial shade', es: 'Brillante a sombra parcial' },
|
||||
temp: '16-24C',
|
||||
},
|
||||
},
|
||||
{
|
||||
botanicalName: 'Anthurium andraeanum',
|
||||
names: { de: 'Flamingoblume', en: 'Anthurium', es: 'Anturio' },
|
||||
descriptions: {
|
||||
de: 'Auffaellige Hochblaetter und lange Bluetezeit. Braucht Waerme und gleichmaessige Feuchte.',
|
||||
en: 'Known for vivid spathes and long bloom cycles. Likes warmth and even moisture.',
|
||||
es: 'Conocida por sus flores vistosas. Requiere calor y riego regular.',
|
||||
},
|
||||
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/8b/AnthuriumAndraenum.jpg/500px-AnthuriumAndraenum.jpg',
|
||||
categories: ['flowering', 'high_humidity'],
|
||||
baseConfidence: 0.71,
|
||||
care: {
|
||||
waterIntervalDays: 6,
|
||||
light: { de: 'Hell ohne direkte Sonne', en: 'Bright indirect light', es: 'Luz brillante indirecta' },
|
||||
temp: '18-27C',
|
||||
},
|
||||
},
|
||||
{
|
||||
botanicalName: 'Nephrolepis exaltata',
|
||||
names: { de: 'Schwertfarn', en: 'Boston Fern', es: 'Helecho de Boston' },
|
||||
descriptions: {
|
||||
de: 'Fein gefiederter Farn. Benoetigt regelmaessige Feuchte und keine direkte Sonne.',
|
||||
en: 'Fine-textured fern. Needs regular moisture and no harsh direct sun.',
|
||||
es: 'Helecho de textura fina. Necesita humedad constante.',
|
||||
},
|
||||
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/3/30/Boston_Fern_%282873392811%29.png/500px-Boston_Fern_%282873392811%29.png',
|
||||
categories: ['pet_friendly', 'air_purifier', 'high_humidity'],
|
||||
baseConfidence: 0.7,
|
||||
care: {
|
||||
waterIntervalDays: 3,
|
||||
light: { de: 'Halbschatten', en: 'Partial shade', es: 'Sombra parcial' },
|
||||
temp: '16-24C',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const hashString = (value: string): number => {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
hash ^= value.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return hash >>> 0;
|
||||
};
|
||||
|
||||
const clampConfidence = (value: number): number => {
|
||||
return Number(Math.max(0.4, Math.min(0.99, value)).toFixed(2));
|
||||
};
|
||||
|
||||
const toEntry = (
|
||||
seed: MockPlantSeed,
|
||||
lang: Language,
|
||||
confidence: number,
|
||||
): BackendDatabaseEntry => {
|
||||
return {
|
||||
name: seed.names[lang],
|
||||
botanicalName: seed.botanicalName,
|
||||
description: seed.descriptions[lang],
|
||||
confidence: clampConfidence(confidence),
|
||||
careInfo: {
|
||||
waterIntervalDays: seed.care.waterIntervalDays,
|
||||
light: seed.care.light[lang],
|
||||
temp: seed.care.temp,
|
||||
},
|
||||
imageUri: seed.imageUri,
|
||||
categories: seed.categories,
|
||||
};
|
||||
};
|
||||
|
||||
const getFullCatalog = (lang: Language): BackendDatabaseEntry[] => {
|
||||
const seeds = PLANTS.map(seed => toEntry(seed, lang, seed.baseConfidence));
|
||||
const batches = getBatchCatalog(lang);
|
||||
const catalog = [...seeds, ...batches];
|
||||
|
||||
const seenBotanical = new Set<string>();
|
||||
const seenImage = new Set<string>();
|
||||
|
||||
return catalog.filter(e => {
|
||||
const botanicalKey = normalizeSearchText(e.botanicalName);
|
||||
const imageKey = e.imageUri.trim();
|
||||
|
||||
if (seenBotanical.has(botanicalKey) || seenImage.has(imageKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seenBotanical.add(botanicalKey);
|
||||
seenImage.add(imageKey);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
export const getMockCatalog = (lang: Language): BackendDatabaseEntry[] => {
|
||||
return getFullCatalog(lang);
|
||||
};
|
||||
|
||||
export const getMockPlantByImage = (
|
||||
imageUri: string,
|
||||
lang: Language,
|
||||
preferHighConfidence: boolean,
|
||||
): BackendDatabaseEntry => {
|
||||
const seeds = PLANTS;
|
||||
const indexHash = hashString(`${lang}:${imageUri}`);
|
||||
const selectedSeed = seeds[indexHash % seeds.length];
|
||||
|
||||
const varianceHash = hashString(`confidence:${imageUri}:${selectedSeed.botanicalName}`);
|
||||
const variance = (varianceHash % 28) / 100; // 0.00 - 0.27
|
||||
const confidenceBase = selectedSeed.baseConfidence - 0.08 + variance;
|
||||
const confidence = preferHighConfidence ? confidenceBase + 0.09 : confidenceBase;
|
||||
|
||||
return toEntry(selectedSeed, lang, confidence);
|
||||
};
|
||||
|
||||
const getBatchCatalog = (lang: Language): BackendDatabaseEntry[] => {
|
||||
const all = [...LEXICON_BATCH_1_ENTRIES, ...LEXICON_BATCH_2_ENTRIES];
|
||||
return all.map(entry => ({
|
||||
name: entry.name,
|
||||
botanicalName: entry.botanicalName,
|
||||
description: entry.description || '',
|
||||
confidence: entry.confidence,
|
||||
careInfo: {
|
||||
waterIntervalDays: entry.careInfo.waterIntervalDays,
|
||||
light: entry.careInfo.light,
|
||||
temp: entry.careInfo.temp,
|
||||
},
|
||||
imageUri: entry.imageUri,
|
||||
categories: entry.categories,
|
||||
}));
|
||||
};
|
||||
|
||||
export const searchMockCatalog = (
|
||||
query: string,
|
||||
lang: Language,
|
||||
limit = 12,
|
||||
): BackendDatabaseEntry[] => {
|
||||
const normalizedQuery = normalizeSearchText(query);
|
||||
const deduped = getFullCatalog(lang);
|
||||
|
||||
if (!normalizedQuery) return deduped.slice(0, limit);
|
||||
|
||||
return rankHybridEntries(deduped, normalizedQuery, limit)
|
||||
.map((candidate) => candidate.entry);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,17 @@
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
|
||||
const SESSION_KEY = 'greenlens_session_v3';
|
||||
|
||||
export const getAuthToken = async (): Promise<string> => {
|
||||
try {
|
||||
const raw = await SecureStore.getItemAsync(SESSION_KEY);
|
||||
if (raw) {
|
||||
const session = JSON.parse(raw);
|
||||
if (typeof session?.token === 'string' && session.token) {
|
||||
return session.token;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
// Return 'guest' instead of throwing to allow guest mode
|
||||
return 'guest';
|
||||
};
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
|
||||
const SESSION_KEY = 'greenlens_session_v3';
|
||||
|
||||
export const getAuthToken = async (): Promise<string> => {
|
||||
try {
|
||||
const raw = await SecureStore.getItemAsync(SESSION_KEY);
|
||||
if (raw) {
|
||||
const session = JSON.parse(raw);
|
||||
if (typeof session?.token === 'string' && session.token) {
|
||||
return session.token;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
// Return 'guest' instead of throwing to allow guest mode
|
||||
return 'guest';
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user