Compare commits

..

1 Commits

Author SHA1 Message Date
Flatlogic Bot
a0d23b6018 Ikiguzi Application 2026-03-26 10:27:56 +00:00
10 changed files with 1376 additions and 150 deletions

0
.perm_test_apache Normal file
View File

0
.perm_test_exec Normal file
View File

View File

@ -18,7 +18,7 @@ const sqlRoutes = require('./routes/sql');
const pexelsRoutes = require('./routes/pexels'); const pexelsRoutes = require('./routes/pexels');
const openaiRoutes = require('./routes/openai'); const openaiRoutes = require('./routes/openai');
const ikiguziRoutes = require('./routes/ikiguzi');
const usersRoutes = require('./routes/users'); const usersRoutes = require('./routes/users');
@ -131,6 +131,8 @@ app.use('/api/cooperatives', passport.authenticate('jwt', {session: false}), coo
app.use('/api/cooperative_memberships', passport.authenticate('jwt', {session: false}), cooperative_membershipsRoutes); app.use('/api/cooperative_memberships', passport.authenticate('jwt', {session: false}), cooperative_membershipsRoutes);
app.use('/api/ikiguzi', passport.authenticate('jwt', { session: false }), ikiguziRoutes);
app.use( app.use(
'/api/openai', '/api/openai',
passport.authenticate('jwt', { session: false }), passport.authenticate('jwt', { session: false }),

View File

@ -0,0 +1,24 @@
const express = require('express');
const IkiguziService = require('../services/ikiguzi');
const wrapAsync = require('../helpers').wrapAsync;
const router = express.Router();
router.get(
'/overview',
wrapAsync(async (req, res) => {
const payload = await IkiguziService.getOverview(req.query.cropId, req.currentUser);
res.status(200).send(payload);
}),
);
router.post(
'/quick-log',
wrapAsync(async (req, res) => {
const payload = await IkiguziService.quickLogCost(req.body, req.currentUser);
res.status(200).send(payload);
}),
);
module.exports = router;

View File

@ -0,0 +1,447 @@
const db = require('../db/models');
const { Op } = db.Sequelize;
module.exports = class IkiguziService {
static normalizeNumber(value, fieldName, options = {}) {
const { required = false, min = 0 } = options;
if (value === undefined || value === null || value === '') {
if (required) {
const error = new Error(`${fieldName} is required`);
error.code = 400;
throw error;
}
return null;
}
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < min) {
const error = new Error(`${fieldName} must be a valid number`);
error.code = 400;
throw error;
}
return parsed;
}
static serializeCostRecord(record) {
return {
id: record.id,
cost_type: record.cost_type,
amount: Number(record.amount || 0),
currency: record.currency,
vendor_name: record.vendor_name,
notes: record.notes,
entry_date: record.entry_date,
cropId: record.cropId,
createdAt: record.createdAt,
};
}
static serializePrediction(record) {
if (!record) {
return null;
}
return {
id: record.id,
predicted_price_per_unit: Number(record.predicted_price_per_unit || 0),
currency: record.currency,
unit_name: record.unit_name,
confidence_percent: record.confidence_percent,
model_name: record.model_name,
explanation: record.explanation,
generated_at: record.generated_at,
valid_until: record.valid_until,
alert_triggered: record.alert_triggered,
};
}
static serializeMarketPrice(record) {
if (!record) {
return null;
}
return {
id: record.id,
crop_name: record.crop_name,
market_name: record.market_name,
region_name: record.region_name,
price_per_unit: Number(record.price_per_unit || 0),
currency: record.currency,
unit_name: record.unit_name,
price_date: record.price_date,
data_quality_note: record.data_quality_note,
};
}
static serializeThreshold(record) {
if (!record) {
return null;
}
return {
id: record.id,
crop_name: record.crop_name,
region_name: record.region_name,
target_price_per_unit: Number(record.target_price_per_unit || 0),
currency: record.currency,
unit_name: record.unit_name,
is_enabled: record.is_enabled,
effective_from: record.effective_from,
effective_until: record.effective_until,
updatedAt: record.updatedAt,
};
}
static buildRecommendation({ latestPrediction, latestPrice, threshold }) {
if (!latestPrediction && !latestPrice) {
return {
tone: 'build',
title: 'Waiting for market intelligence',
message:
'Add a market prediction or sync price data to unlock a stronger sell recommendation for this crop.',
};
}
if (!threshold) {
return {
tone: 'watch',
title: 'Good tracking foundation',
message:
'You are recording costs and market signals. Add a target selling price to receive a clearer sell-or-wait signal.',
};
}
const predictedPrice = latestPrediction
? Number(latestPrediction.predicted_price_per_unit || 0)
: null;
const observedPrice = latestPrice
? Number(latestPrice.price_per_unit || 0)
: null;
const targetPrice = Number(threshold.target_price_per_unit || 0);
const predictionReady = predictedPrice !== null && predictedPrice >= targetPrice;
const marketReady = observedPrice !== null && observedPrice >= targetPrice;
if (predictionReady && marketReady) {
return {
tone: 'sell',
title: 'Strong selling window',
message:
'Both the latest market price and the AI forecast are above your target price. This crop is ready for a selling alert.',
};
}
if (predictionReady) {
return {
tone: 'watch',
title: 'Forecast looks promising',
message:
'The AI prediction is above your target price, but the live market price has not fully caught up yet. Keep watching the next update.',
};
}
if (marketReady) {
return {
tone: 'watch',
title: 'Market spike detected',
message:
'The latest market price meets your target, but the prediction is more cautious. Consider selling part of your stock and monitoring the trend.',
};
}
return {
tone: 'build',
title: 'Hold and monitor',
message:
'Current prices are still below your target. Keep tracking costs and wait for a stronger selling signal.',
};
}
static async getOverview(cropId, currentUser) {
let crop = null;
if (cropId) {
crop = await db.crops.findByPk(cropId);
}
if (!crop) {
crop = await db.crops.findOne({
order: [['createdAt', 'DESC']],
});
}
if (!crop) {
return {
crop: null,
totals: {
totalCost: 0,
entryCount: 0,
breakdown: [],
},
latestPrediction: null,
latestPrice: null,
threshold: null,
recentCostRecords: [],
recentPredictions: [],
recentPrices: [],
recommendation: {
tone: 'build',
title: 'Create your first crop',
message:
'Start by registering a crop. Then you can log production costs, compare them with market forecasts, and activate selling alerts.',
},
};
}
const totalCostRow = await db.cost_records.findOne({
attributes: [
[db.sequelize.fn('COALESCE', db.sequelize.fn('SUM', db.sequelize.col('amount')), 0), 'totalCost'],
[db.sequelize.fn('COUNT', db.sequelize.col('id')), 'entryCount'],
],
where: {
cropId: crop.id,
},
raw: true,
});
const breakdownRows = await db.cost_records.findAll({
attributes: [
'cost_type',
[db.sequelize.fn('COALESCE', db.sequelize.fn('SUM', db.sequelize.col('amount')), 0), 'total'],
],
where: {
cropId: crop.id,
},
group: ['cost_type'],
raw: true,
});
const recentCostRecords = await db.cost_records.findAll({
where: {
cropId: crop.id,
},
order: [
['entry_date', 'DESC'],
['createdAt', 'DESC'],
],
limit: 5,
});
const recentPredictions = await db.market_predictions.findAll({
where: {
cropId: crop.id,
},
order: [
['generated_at', 'DESC'],
['createdAt', 'DESC'],
],
limit: 4,
});
const marketPriceWhere = {
crop_name: crop.crop_name,
};
if (crop.region_name) {
marketPriceWhere.region_name = {
[Op.or]: [crop.region_name, null],
};
}
const recentPrices = await db.market_prices.findAll({
where: marketPriceWhere,
order: [
['price_date', 'DESC'],
['createdAt', 'DESC'],
],
limit: 4,
});
const thresholdWhere = {
crop_name: crop.crop_name,
is_enabled: true,
};
if (currentUser && currentUser.id) {
thresholdWhere.userId = currentUser.id;
}
if (crop.region_name) {
thresholdWhere.region_name = crop.region_name;
}
const latestThreshold = await db.selling_thresholds.findOne({
where: thresholdWhere,
order: [
['effective_from', 'DESC'],
['updatedAt', 'DESC'],
],
});
const latestPrediction = recentPredictions[0] || null;
const latestPrice = recentPrices[0] || null;
return {
crop: {
id: crop.id,
crop_name: crop.crop_name,
crop_category: crop.crop_category,
season_name: crop.season_name,
region_name: crop.region_name,
area_hectares: Number(crop.area_hectares || 0),
planting_date: crop.planting_date,
expected_harvest_date: crop.expected_harvest_date,
},
totals: {
totalCost: Number(totalCostRow?.totalCost || 0),
entryCount: Number(totalCostRow?.entryCount || 0),
breakdown: breakdownRows
.map((row) => ({
cost_type: row.cost_type,
total: Number(row.total || 0),
}))
.sort((left, right) => right.total - left.total),
},
latestPrediction: IkiguziService.serializePrediction(latestPrediction),
latestPrice: IkiguziService.serializeMarketPrice(latestPrice),
threshold: IkiguziService.serializeThreshold(latestThreshold),
recentCostRecords: recentCostRecords.map(IkiguziService.serializeCostRecord),
recentPredictions: recentPredictions.map(IkiguziService.serializePrediction),
recentPrices: recentPrices.map(IkiguziService.serializeMarketPrice),
recommendation: IkiguziService.buildRecommendation({
latestPrediction,
latestPrice,
threshold: latestThreshold,
}),
};
}
static async quickLogCost(data, currentUser) {
if (!currentUser || !currentUser.id) {
const error = new Error('Authentication is required');
error.code = 401;
throw error;
}
const cropId = data.cropId;
const costType = data.costType;
const amount = IkiguziService.normalizeNumber(data.amount, 'amount', {
required: true,
min: 0,
});
const targetPricePerUnit = IkiguziService.normalizeNumber(
data.targetPricePerUnit,
'targetPricePerUnit',
{ min: 0 },
);
if (!cropId) {
const error = new Error('cropId is required');
error.code = 400;
throw error;
}
if (!costType) {
const error = new Error('costType is required');
error.code = 400;
throw error;
}
const crop = await db.crops.findByPk(cropId);
if (!crop) {
const error = new Error('Crop not found');
error.code = 404;
throw error;
}
const transaction = await db.sequelize.transaction();
try {
await db.cost_records.create(
{
cropId,
entry_date: data.entryDate || new Date(),
cost_type: costType,
amount,
currency: data.currency || 'RWF',
vendor_name: data.vendorName || null,
notes: data.notes || null,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
let thresholdAction = null;
if (targetPricePerUnit !== null) {
const thresholdWhere = {
userId: currentUser.id,
crop_name: crop.crop_name,
is_enabled: true,
};
if (crop.region_name) {
thresholdWhere.region_name = crop.region_name;
}
const existingThreshold = await db.selling_thresholds.findOne({
where: thresholdWhere,
order: [
['effective_from', 'DESC'],
['updatedAt', 'DESC'],
],
transaction,
});
const thresholdPayload = {
userId: currentUser.id,
crop_name: crop.crop_name,
region_name: crop.region_name || null,
target_price_per_unit: targetPricePerUnit,
unit_name: data.unitName || 'kg',
currency: data.currency || 'RWF',
is_enabled: true,
effective_from: data.entryDate || new Date(),
updatedById: currentUser.id,
};
if (existingThreshold) {
await existingThreshold.update(thresholdPayload, { transaction });
thresholdAction = 'updated';
} else {
await db.selling_thresholds.create(
{
...thresholdPayload,
createdById: currentUser.id,
},
{ transaction },
);
thresholdAction = 'created';
}
}
await transaction.commit();
const overview = await IkiguziService.getOverview(cropId, currentUser);
const actionText = thresholdAction
? `Logged ${costType} cost for ${crop.crop_name} and ${thresholdAction} your sell alert.`
: `Logged ${costType} cost for ${crop.crop_name}.`;
return {
message: actionText,
overview,
};
} catch (error) {
await transaction.rollback();
throw error;
}
}
};

View File

@ -1,6 +1,5 @@
import React, {useEffect, useRef} from 'react' import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider' import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon' import BaseIcon from './BaseIcon'

View File

@ -1,5 +1,4 @@
import React, { ReactNode, useEffect } from 'react' import React, { ReactNode, useEffect, useState } from 'react'
import { useState } from 'react'
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside' import menuAside from '../menuAside'

View File

@ -7,6 +7,11 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiViewDashboardOutline, icon: icon.mdiViewDashboardOutline,
label: 'Dashboard', label: 'Dashboard',
}, },
{
href: '/farm-workspace',
icon: icon.mdiChartTimelineVariant,
label: 'Farm workspace',
},
{ {
href: '/users/users-list', href: '/users/users-list',

View File

@ -0,0 +1,732 @@
import {
mdiBellAlert,
mdiCashMultiple,
mdiChartTimelineVariant,
mdiRobot,
} from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import React, { type ChangeEvent, type FormEvent, type ReactElement, useEffect, useState } from 'react';
import BaseButton from '../components/BaseButton';
import BaseButtons from '../components/BaseButtons';
import BaseDivider from '../components/BaseDivider';
import CardBox from '../components/CardBox';
import FormField from '../components/FormField';
import NotificationBar from '../components/NotificationBar';
import SectionMain from '../components/SectionMain';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import { getPageTitle } from '../config';
import LayoutAuthenticated from '../layouts/Authenticated';
import { useAppSelector } from '../stores/hooks';
type CropOption = {
id: string;
crop_name: string;
crop_category?: string;
region_name?: string;
season_name?: string;
area_hectares?: number | string;
};
type OverviewData = {
crop: CropOption | null;
totals: {
totalCost: number;
entryCount: number;
breakdown: Array<{
cost_type: string;
total: number;
}>;
};
latestPrediction: any;
latestPrice: any;
threshold: any;
recentCostRecords: any[];
recentPredictions: any[];
recentPrices: any[];
recommendation: {
tone: 'sell' | 'watch' | 'build';
title: string;
message: string;
};
};
type QuickLogForm = {
cropId: string;
costType: string;
amount: string;
currency: string;
entryDate: string;
vendorName: string;
targetPricePerUnit: string;
unitName: string;
notes: string;
};
const toLocalDateTimeInput = (value?: string | Date) => {
const date = value ? new Date(value) : new Date();
const timezoneOffset = date.getTimezoneOffset() * 60_000;
return new Date(date.getTime() - timezoneOffset).toISOString().slice(0, 16);
};
const defaultFormValues = (): QuickLogForm => ({
cropId: '',
costType: 'seeds',
amount: '',
currency: 'RWF',
entryDate: toLocalDateTimeInput(),
vendorName: '',
targetPricePerUnit: '',
unitName: 'kg',
notes: '',
});
const formatMoney = (amount?: number | string | null, currency = 'RWF') => {
const numericAmount = Number(amount || 0);
return `${currency} ${numericAmount.toLocaleString('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
})}`;
};
const formatDate = (value?: string | Date | null) => {
if (!value) {
return 'Not set';
}
return new Date(value).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
const toneStyles: Record<string, { badge: string; panel: string }> = {
sell: {
badge: 'bg-emerald-100 text-emerald-700',
panel: 'border-emerald-200 bg-emerald-50',
},
watch: {
badge: 'bg-amber-100 text-amber-700',
panel: 'border-amber-200 bg-amber-50',
},
build: {
badge: 'bg-sky-100 text-sky-700',
panel: 'border-sky-200 bg-sky-50',
},
};
const FarmWorkspace = () => {
const { currentUser } = useAppSelector((state) => state.auth);
const [crops, setCrops] = useState<CropOption[]>([]);
const [formState, setFormState] = useState<QuickLogForm>(defaultFormValues);
const [overview, setOverview] = useState<OverviewData | null>(null);
const [isLoadingCrops, setIsLoadingCrops] = useState(true);
const [isLoadingOverview, setIsLoadingOverview] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [successMessage, setSuccessMessage] = useState('');
const [errorMessage, setErrorMessage] = useState('');
useEffect(() => {
const loadCrops = async () => {
try {
setIsLoadingCrops(true);
const response = await axios.get('/crops?limit=100&page=0');
const nextCrops = Array.isArray(response.data?.rows) ? response.data.rows : [];
setCrops(nextCrops);
const firstCropId = nextCrops[0]?.id || '';
setFormState((previousState) => ({
...previousState,
cropId: previousState.cropId || firstCropId,
}));
} catch (error: any) {
console.error('Failed to load crops for workspace', error);
setErrorMessage(error?.response?.data || 'Unable to load crops right now.');
} finally {
setIsLoadingCrops(false);
}
};
loadCrops();
}, []);
useEffect(() => {
const loadOverview = async () => {
if (!formState.cropId) {
setOverview(null);
return;
}
try {
setIsLoadingOverview(true);
const response = await axios.get(`/ikiguzi/overview?cropId=${formState.cropId}`);
setOverview(response.data);
} catch (error: any) {
console.error('Failed to load Ikiguzi overview', error);
setErrorMessage(error?.response?.data || 'Unable to load crop insights right now.');
} finally {
setIsLoadingOverview(false);
}
};
loadOverview();
}, [formState.cropId]);
const handleInputChange = (
event: ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>,
) => {
const { name, value } = event.target;
setFormState((previousState) => ({
...previousState,
[name]: value,
}));
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSuccessMessage('');
setErrorMessage('');
if (!formState.cropId) {
setErrorMessage('Please select a crop before logging a cost.');
return;
}
if (!formState.amount || Number(formState.amount) <= 0) {
setErrorMessage('Please enter a valid cost amount greater than zero.');
return;
}
try {
setIsSubmitting(true);
const response = await axios.post('/ikiguzi/quick-log', formState);
setSuccessMessage(response.data?.message || 'Cost logged successfully.');
setOverview(response.data?.overview || null);
setFormState((previousState) => ({
...defaultFormValues(),
cropId: previousState.cropId,
currency: previousState.currency,
targetPricePerUnit: previousState.targetPricePerUnit,
unitName: previousState.unitName,
}));
} catch (error: any) {
console.error('Failed to submit quick log', error);
setErrorMessage(error?.response?.data || 'Unable to save this entry. Please try again.');
} finally {
setIsSubmitting(false);
}
};
const selectedCrop = crops.find((crop) => crop.id === formState.cropId) || overview?.crop || null;
const recommendationTone = overview?.recommendation?.tone || 'build';
const recommendationStyle = toneStyles[recommendationTone] || toneStyles.build;
return (
<>
<Head>
<title>{getPageTitle('Farm workspace')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Farm workspace" main>
{''}
</SectionTitleLineWithButton>
<div className="mb-6 overflow-hidden rounded-3xl bg-gradient-to-br from-emerald-950 via-emerald-800 to-sky-600 p-8 text-white shadow-2xl">
<div className="grid gap-6 lg:grid-cols-[1.3fr,0.9fr] lg:items-end">
<div>
<div className="mb-4 inline-flex items-center rounded-full bg-white/15 px-4 py-2 text-sm font-medium text-emerald-50 backdrop-blur">
Ikiguzi MVP slice · cost logging + sell signal
</div>
<h1 className="max-w-3xl text-4xl font-bold leading-tight md:text-5xl">
{currentUser?.firstName ? `Welcome back, ${currentUser.firstName}. ` : ''}
Track farm costs fast, compare them with market signals, and prepare a smarter selling move.
</h1>
<p className="mt-4 max-w-2xl text-base text-emerald-50/90 md:text-lg">
This workspace gives farmers one practical flow: log a production cost, keep a target selling price,
and immediately see whether the crop looks ready to sell, watch, or hold.
</p>
<BaseButtons className="mt-6" type="justify-start" noWrap={false}>
<BaseButton href="/crops/crops-new" color="info" label="Register crop" />
<BaseButton href="/market_predictions/market_predictions-list" color="whiteDark" label="Review AI predictions" />
<BaseButton href="/selling_thresholds/selling_thresholds-list" color="whiteDark" label="Open alerts" />
</BaseButtons>
</div>
<div className="grid gap-4 sm:grid-cols-3 lg:grid-cols-1">
<div className="rounded-2xl border border-white/15 bg-white/10 p-4 backdrop-blur">
<p className="text-sm uppercase tracking-[0.2em] text-emerald-100/70">Tracked cost</p>
<p className="mt-2 text-3xl font-semibold">
{formatMoney(overview?.totals?.totalCost || 0, formState.currency)}
</p>
<p className="mt-1 text-sm text-emerald-50/80">Across {overview?.totals?.entryCount || 0} logged entries</p>
</div>
<div className="rounded-2xl border border-white/15 bg-white/10 p-4 backdrop-blur">
<p className="text-sm uppercase tracking-[0.2em] text-emerald-100/70">Latest forecast</p>
<p className="mt-2 text-3xl font-semibold">
{overview?.latestPrediction
? formatMoney(
overview.latestPrediction.predicted_price_per_unit,
overview.latestPrediction.currency,
)
: '—'}
</p>
<p className="mt-1 text-sm text-emerald-50/80">
{overview?.latestPrediction
? `${overview.latestPrediction.confidence_percent || 0}% confidence`
: 'No prediction yet'}
</p>
</div>
<div className="rounded-2xl border border-white/15 bg-white/10 p-4 backdrop-blur">
<p className="text-sm uppercase tracking-[0.2em] text-emerald-100/70">Target alert</p>
<p className="mt-2 text-3xl font-semibold">
{overview?.threshold
? formatMoney(
overview.threshold.target_price_per_unit,
overview.threshold.currency,
)
: 'Off'}
</p>
<p className="mt-1 text-sm text-emerald-50/80">
{overview?.threshold ? `Per ${overview.threshold.unit_name || 'unit'}` : 'Set during quick log'}
</p>
</div>
</div>
</div>
</div>
{successMessage ? (
<NotificationBar color="success" icon={mdiBellAlert}>
{successMessage}
</NotificationBar>
) : null}
{errorMessage ? (
<NotificationBar color="danger" icon={mdiBellAlert}>
{errorMessage}
</NotificationBar>
) : null}
{isLoadingCrops ? (
<CardBox className="mb-6">
<div className="space-y-2">
<p className="text-lg font-semibold text-gray-900">Loading farm workspace</p>
<p className="text-sm text-gray-500">We are preparing your crop list and the latest pricing context.</p>
</div>
</CardBox>
) : null}
{!isLoadingCrops && crops.length === 0 ? (
<CardBox className="mb-6">
<div className="grid gap-4 md:grid-cols-[1.4fr,auto] md:items-center">
<div>
<p className="text-lg font-semibold text-gray-900">No crops yet</p>
<p className="mt-2 text-sm text-gray-500">
Your first useful workflow starts with one crop record. After that, you can log costs and monitor
selling signals in this workspace.
</p>
</div>
<BaseButtons className="md:justify-end" type="justify-start">
<BaseButton href="/crops/crops-new" color="info" label="Create first crop" />
<BaseButton href="/crops/crops-list" color="whiteDark" label="Browse crops" />
</BaseButtons>
</div>
</CardBox>
) : null}
{!isLoadingCrops && crops.length > 0 ? (
<div className="grid gap-6 xl:grid-cols-[1.1fr,0.9fr]">
<CardBox className="mb-6 xl:mb-0">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-emerald-700">Step 1</p>
<h2 className="mt-2 text-2xl font-semibold text-gray-900">Quick cost log</h2>
</div>
<div className="rounded-full bg-emerald-100 px-4 py-2 text-sm font-medium text-emerald-700">
Mobile-friendly input
</div>
</div>
<p className="mt-3 text-sm text-gray-500">
Save one real production expense and optionally update the selling target for the crop in the same action.
</p>
<BaseDivider />
<form onSubmit={handleSubmit}>
<FormField label="Crop" labelFor="cropId" help="Choose the crop you are currently tracking.">
<select id="cropId" name="cropId" value={formState.cropId} onChange={handleInputChange}>
{crops.map((crop) => (
<option key={crop.id} value={crop.id}>
{crop.crop_name} · {crop.region_name || 'Region pending'}
</option>
))}
</select>
</FormField>
<div className="grid gap-4 md:grid-cols-2">
<FormField label="Cost type" labelFor="costType">
<select id="costType" name="costType" value={formState.costType} onChange={handleInputChange}>
<option value="seeds">Seeds</option>
<option value="fertilizer">Fertilizer</option>
<option value="labor">Labor</option>
<option value="transport">Transport</option>
<option value="equipment">Equipment</option>
<option value="irrigation">Irrigation</option>
<option value="storage">Storage</option>
<option value="other">Other</option>
</select>
</FormField>
<FormField label="Entry date" labelFor="entryDate">
<input
id="entryDate"
name="entryDate"
type="datetime-local"
value={formState.entryDate}
onChange={handleInputChange}
/>
</FormField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField label="Amount" labelFor="amount" help="Use the same currency you want in your farm reports.">
<input
id="amount"
name="amount"
type="number"
min="0"
step="0.01"
placeholder="25000"
value={formState.amount}
onChange={handleInputChange}
/>
</FormField>
<FormField label="Currency" labelFor="currency">
<input
id="currency"
name="currency"
placeholder="RWF"
value={formState.currency}
onChange={handleInputChange}
/>
</FormField>
</div>
<FormField label="Vendor or source" labelFor="vendorName">
<input
id="vendorName"
name="vendorName"
placeholder="Input supplier, shop, or labor source"
value={formState.vendorName}
onChange={handleInputChange}
/>
</FormField>
<div className="grid gap-4 md:grid-cols-2">
<FormField
label="Target selling price"
labelFor="targetPricePerUnit"
help="Optional. If you add it, Ikiguzi updates your active selling threshold."
>
<input
id="targetPricePerUnit"
name="targetPricePerUnit"
type="number"
min="0"
step="0.01"
placeholder="540"
value={formState.targetPricePerUnit}
onChange={handleInputChange}
/>
</FormField>
<FormField label="Unit" labelFor="unitName">
<input
id="unitName"
name="unitName"
placeholder="kg"
value={formState.unitName}
onChange={handleInputChange}
/>
</FormField>
</div>
<FormField label="Notes" labelFor="notes" hasTextareaHeight>
<textarea
id="notes"
name="notes"
placeholder="Add context such as quality, season, or supplier notes"
value={formState.notes}
onChange={handleInputChange}
/>
</FormField>
<BaseButtons>
<BaseButton type="submit" color="info" label={isSubmitting ? 'Saving…' : 'Save cost entry'} disabled={isSubmitting} />
<BaseButton href="/cost_records/cost_records-list" color="whiteDark" label="Open full cost log" />
</BaseButtons>
</form>
</CardBox>
<div className="space-y-6">
<div className={`rounded-3xl border p-6 shadow-sm ${recommendationStyle.panel}`}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-gray-600">Step 2</p>
<h2 className="mt-2 text-2xl font-semibold text-gray-900">Selling signal</h2>
</div>
<span className={`rounded-full px-4 py-2 text-sm font-medium ${recommendationStyle.badge}`}>
{recommendationTone.toUpperCase()}
</span>
</div>
<p className="mt-5 text-2xl font-semibold text-gray-900">{overview?.recommendation?.title || 'Build your signal'}</p>
<p className="mt-3 text-sm leading-6 text-gray-600">
{overview?.recommendation?.message || 'Select a crop and start logging costs to see market guidance.'}
</p>
<div className="mt-6 grid gap-4 sm:grid-cols-2">
<div className="rounded-2xl bg-white/80 p-4">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500">Latest market price</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">
{overview?.latestPrice
? formatMoney(overview.latestPrice.price_per_unit, overview.latestPrice.currency)
: 'No live price'}
</p>
<p className="mt-1 text-sm text-gray-500">
{overview?.latestPrice
? `${overview.latestPrice.market_name || 'Market source'} · ${formatDate(overview.latestPrice.price_date)}`
: 'Import market prices to populate this block'}
</p>
</div>
<div className="rounded-2xl bg-white/80 p-4">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500">Latest AI forecast</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">
{overview?.latestPrediction
? formatMoney(
overview.latestPrediction.predicted_price_per_unit,
overview.latestPrediction.currency,
)
: 'No forecast'}
</p>
<p className="mt-1 text-sm text-gray-500">
{overview?.latestPrediction
? `${overview.latestPrediction.model_name || 'Model'} · ${overview.latestPrediction.confidence_percent || 0}% confidence`
: 'Create or import predictions to compare selling windows'}
</p>
</div>
</div>
<BaseButtons className="mt-6" type="justify-start">
<BaseButton href="/market_predictions/market_predictions-new" color="info" label="Add AI prediction" />
<BaseButton href="/market_prices/market_prices-list" color="whiteDark" label="Check market prices" />
</BaseButtons>
</div>
<CardBox>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-emerald-700">Step 3</p>
<h2 className="mt-2 text-2xl font-semibold text-gray-900">Crop snapshot</h2>
</div>
{isLoadingOverview ? (
<span className="rounded-full bg-gray-100 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-gray-500">
Refreshing
</span>
) : null}
</div>
<BaseDivider />
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-2xl bg-slate-50 p-4">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Selected crop</p>
<p className="mt-2 text-xl font-semibold text-slate-900">{selectedCrop?.crop_name || 'Select crop'}</p>
<p className="mt-1 text-sm text-slate-500">
{selectedCrop?.region_name || 'Region pending'} · {selectedCrop?.season_name || 'Season not set'}
</p>
</div>
<div className="rounded-2xl bg-slate-50 p-4">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Tracked area</p>
<p className="mt-2 text-xl font-semibold text-slate-900">
{selectedCrop?.area_hectares ? `${selectedCrop.area_hectares} ha` : 'Area not set'}
</p>
<p className="mt-1 text-sm text-slate-500">
Harvest target: {formatDate((overview?.crop as any)?.expected_harvest_date)}
</p>
</div>
</div>
<div className="mt-4 rounded-2xl bg-emerald-50 p-4">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-700">Cost mix</p>
<div className="mt-3 flex flex-wrap gap-2">
{(overview?.totals?.breakdown || []).length > 0 ? (
overview?.totals?.breakdown?.map((item) => (
<div key={item.cost_type} className="rounded-full bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm">
{item.cost_type}: {formatMoney(item.total, formState.currency)}
</div>
))
) : (
<p className="text-sm text-emerald-800">No cost entries yet for this crop.</p>
)}
</div>
</div>
</CardBox>
</div>
</div>
) : null}
{!isLoadingCrops && crops.length > 0 ? (
<div className="mt-6 grid gap-6 xl:grid-cols-3">
<CardBox>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="rounded-2xl bg-emerald-100 p-3 text-emerald-700">
<span className="sr-only">Cost records</span>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={mdiCashMultiple} />
</svg>
</div>
<div>
<h3 className="text-xl font-semibold text-gray-900">Recent costs</h3>
<p className="text-sm text-gray-500">Last entries for this crop</p>
</div>
</div>
<BaseButton href="/cost_records/cost_records-list" color="whiteDark" label="View all" small />
</div>
<BaseDivider />
<div className="space-y-3">
{overview?.recentCostRecords?.length ? (
overview.recentCostRecords.map((record) => (
<div key={record.id} className="rounded-2xl border border-gray-100 bg-slate-50 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="font-semibold capitalize text-gray-900">{record.cost_type}</p>
<p className="text-sm text-gray-500">{record.vendor_name || 'No vendor provided'}</p>
</div>
<p className="text-sm font-semibold text-gray-900">{formatMoney(record.amount, record.currency)}</p>
</div>
<div className="mt-3 flex items-center justify-between gap-3 text-sm text-gray-500">
<span>{formatDate(record.entry_date)}</span>
<BaseButton href={`/cost_records/${record.id}`} color="whiteDark" label="Details" small />
</div>
</div>
))
) : (
<p className="rounded-2xl bg-slate-50 p-4 text-sm text-gray-500">
No costs have been logged for this crop yet. Add your first expense to start the timeline.
</p>
)}
</div>
</CardBox>
<CardBox>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="rounded-2xl bg-sky-100 p-3 text-sky-700">
<span className="sr-only">Predictions</span>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={mdiRobot} />
</svg>
</div>
<div>
<h3 className="text-xl font-semibold text-gray-900">AI predictions</h3>
<p className="text-sm text-gray-500">Latest forecast snapshots</p>
</div>
</div>
<BaseButton href="/market_predictions/market_predictions-list" color="whiteDark" label="View all" small />
</div>
<BaseDivider />
<div className="space-y-3">
{overview?.recentPredictions?.length ? (
overview.recentPredictions.map((prediction) => (
<div key={prediction.id} className="rounded-2xl border border-gray-100 bg-slate-50 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="font-semibold text-gray-900">{formatMoney(prediction.predicted_price_per_unit, prediction.currency)}</p>
<p className="text-sm text-gray-500">{prediction.model_name || 'Model unavailable'}</p>
</div>
<div className="rounded-full bg-white px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-sky-700">
{prediction.confidence_percent || 0}%
</div>
</div>
<div className="mt-3 flex items-center justify-between gap-3 text-sm text-gray-500">
<span>{formatDate(prediction.generated_at)}</span>
<BaseButton href={`/market_predictions/${prediction.id}`} color="whiteDark" label="Details" small />
</div>
</div>
))
) : (
<p className="rounded-2xl bg-slate-50 p-4 text-sm text-gray-500">
No prediction has been added for this crop yet. Create one to compare with your target selling price.
</p>
)}
</div>
</CardBox>
<CardBox>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="rounded-2xl bg-amber-100 p-3 text-amber-700">
<span className="sr-only">Market prices</span>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={mdiBellAlert} />
</svg>
</div>
<div>
<h3 className="text-xl font-semibold text-gray-900">Market watch</h3>
<p className="text-sm text-gray-500">Recent live prices in your crop lane</p>
</div>
</div>
<BaseButton href="/market_prices/market_prices-list" color="whiteDark" label="View all" small />
</div>
<BaseDivider />
<div className="space-y-3">
{overview?.recentPrices?.length ? (
overview.recentPrices.map((price) => (
<div key={price.id} className="rounded-2xl border border-gray-100 bg-slate-50 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="font-semibold text-gray-900">{formatMoney(price.price_per_unit, price.currency)}</p>
<p className="text-sm text-gray-500">{price.market_name || price.region_name || 'Source pending'}</p>
</div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">{price.unit_name || 'unit'}</p>
</div>
<p className="mt-3 text-sm text-gray-500">{formatDate(price.price_date)}</p>
</div>
))
) : (
<p className="rounded-2xl bg-slate-50 p-4 text-sm text-gray-500">
No market price record matches this crop yet. Import prices to strengthen the selling signal.
</p>
)}
</div>
</CardBox>
</div>
) : null}
<div className="mt-6 rounded-3xl border border-emerald-100 bg-white p-6 shadow-sm">
<div className="grid gap-6 lg:grid-cols-3">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-emerald-700">What this slice covers</p>
<h2 className="mt-2 text-2xl font-semibold text-gray-900">One thin workflow, fully connected</h2>
</div>
<div className="rounded-2xl bg-slate-50 p-4">
<p className="text-lg font-semibold text-gray-900">Create</p>
<p className="mt-2 text-sm text-gray-500">Farmers log a real production cost with only the fields needed for day-one value.</p>
</div>
<div className="rounded-2xl bg-slate-50 p-4">
<p className="text-lg font-semibold text-gray-900">Confirm + review</p>
<p className="mt-2 text-sm text-gray-500">The page refreshes the crop insight, highlights selling guidance, and links into list and detail screens.</p>
</div>
</div>
</div>
</SectionMain>
</>
);
};
FarmWorkspace.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default FarmWorkspace;

View File

@ -1,166 +1,184 @@
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import Head from 'next/head'; import Head from 'next/head';
import Link from 'next/link'; import React from 'react';
import BaseButton from '../components/BaseButton'; import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons'; import BaseButtons from '../components/BaseButtons';
import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config'; import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
const features = [
{
title: 'Track every farm cost',
body: 'Capture seeds, fertilizer, labor, transport, and storage in one lightweight workflow that works well on low-end devices.',
},
{
title: 'Read AI selling signals',
body: 'Compare the latest prediction, live market price, and your target threshold to decide whether to sell, watch, or wait.',
},
{
title: 'Work with clean records',
body: 'Move from quick entry to lists, detail pages, and reports without rebuilding the generic CRUD foundation already in the app.',
},
];
export default function Starter() { const workflowSteps = [
const [illustrationImage, setIllustrationImage] = useState({ {
src: undefined, step: '01',
photographer: undefined, title: 'Register a crop',
photographer_url: undefined, body: 'Set up the crop, region, season, and area so every cost and prediction has a real farming context.',
}) },
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []}) {
const [contentType, setContentType] = useState('video'); step: '02',
const [contentPosition, setContentPosition] = useState('right'); title: 'Log costs fast',
const textColor = useAppSelector((state) => state.style.linkColor); body: 'Use the farm workspace to add a cost entry and optionally update the crops target selling price in one action.',
},
{
step: '03',
title: 'Act on the signal',
body: 'Review the recommendation, check recent market prices, and open the admin workflow for deeper records and follow-up.',
},
];
const title = 'Ikiguzi Price Tracker' function HomePage() {
// Fetch Pexels image/video
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
const imageBlock = (image) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return ( return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'> <>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
return (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<Head> <Head>
<title>{getPageTitle('Starter Page')}</title> <title>{getPageTitle('Ikiguzi')}</title>
<meta
name="description"
content="Ikiguzi helps Rwandan farmers track production costs, monitor market prices, and prepare better selling decisions."
/>
</Head> </Head>
<SectionFullScreen bg='violet'> <div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(16,185,129,0.18),_transparent_36%),linear-gradient(180deg,_#F5FFFB_0%,_#F8FAFC_55%,_#FFFFFF_100%)] text-slate-900">
<div <header className="sticky top-0 z-20 border-b border-white/70 bg-white/80 backdrop-blur">
className={`flex ${ <div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row' <div>
} min-h-screen w-full`} <p className="text-xs font-semibold uppercase tracking-[0.32em] text-emerald-700">Ikiguzi</p>
> <p className="text-sm text-slate-500">AI-powered crop cost & market price tracker</p>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your Ikiguzi Price Tracker app!"/>
<div className="space-y-3">
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center text-gray-500'>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
</div> </div>
<BaseButtons type="justify-end" noWrap>
<BaseButtons> <BaseButton href="/dashboard" color="whiteDark" label="Admin interface" />
<BaseButton <BaseButton href="/login" color="info" label="Login" />
href='/login'
label='Login'
color='info'
className='w-full'
/>
</BaseButtons> </BaseButtons>
</CardBox>
</div> </div>
</header>
<main>
<section className="mx-auto grid max-w-7xl gap-8 px-6 pb-12 pt-12 lg:grid-cols-[1.2fr,0.8fr] lg:items-center lg:pb-20 lg:pt-16">
<div>
<div className="mb-4 inline-flex rounded-full border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-800">
Built for smallholder farmers, cooperatives, and extension teams
</div> </div>
</SectionFullScreen> <h1 className="max-w-4xl text-5xl font-bold leading-tight tracking-tight text-slate-950 md:text-6xl">
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'> Turn daily farm expenses into clear, confident selling decisions.
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p> </h1>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'> <p className="mt-6 max-w-2xl text-lg leading-8 text-slate-600">
Privacy Policy Ikiguzi combines crop cost tracking, market monitoring, and simple AI guidance so farmers can understand
</Link> what they spent, what the market is doing, and when it may be the right time to sell.
</p>
<BaseButtons className="mt-8" type="justify-start">
<BaseButton href="/login" color="info" label="Start with login" />
<BaseButton href="/dashboard" color="whiteDark" label="Open admin interface" />
</BaseButtons>
</div> </div>
<div className="grid gap-4">
<div className="overflow-hidden rounded-3xl bg-gradient-to-br from-emerald-950 via-emerald-800 to-sky-600 p-6 text-white shadow-2xl">
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-emerald-100/80">First MVP slice</p>
<p className="mt-4 text-3xl font-semibold">Farm workspace</p>
<p className="mt-3 text-sm leading-7 text-emerald-50/90">
One practical screen for logging costs, updating a target price, and seeing a sell / watch / hold signal.
</p>
<div className="mt-6 grid gap-4 sm:grid-cols-3">
<div className="rounded-2xl bg-white/10 p-4 backdrop-blur">
<p className="text-xs uppercase tracking-[0.2em] text-emerald-100/80">Inputs</p>
<p className="mt-2 text-lg font-semibold">Crop + cost + target</p>
</div> </div>
<div className="rounded-2xl bg-white/10 p-4 backdrop-blur">
<p className="text-xs uppercase tracking-[0.2em] text-emerald-100/80">Signal</p>
<p className="mt-2 text-lg font-semibold">AI + market + threshold</p>
</div>
<div className="rounded-2xl bg-white/10 p-4 backdrop-blur">
<p className="text-xs uppercase tracking-[0.2em] text-emerald-100/80">Follow-up</p>
<p className="mt-2 text-lg font-semibold">Lists + detail pages</p>
</div>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-3xl border border-emerald-100 bg-white p-5 shadow-sm">
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-emerald-700">Why it matters</p>
<p className="mt-3 text-lg font-semibold text-slate-900">Low-friction input for low-literacy contexts</p>
<p className="mt-2 text-sm leading-7 text-slate-600">
The workflow keeps labels simple, focuses on the minimum useful fields, and offers clear next actions.
</p>
</div>
<div className="rounded-3xl border border-sky-100 bg-white p-5 shadow-sm">
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-sky-700">What stays protected</p>
<p className="mt-3 text-lg font-semibold text-slate-900">Public landing, private farm data</p>
<p className="mt-2 text-sm leading-7 text-slate-600">
The homepage stays public, while crop records, alerts, and reports remain inside the authenticated admin interface.
</p>
</div>
</div>
</div>
</section>
<section className="mx-auto max-w-7xl px-6 pb-8">
<div className="grid gap-6 md:grid-cols-3">
{features.map((feature) => (
<div key={feature.title} className="rounded-3xl border border-slate-100 bg-white p-6 shadow-sm">
<p className="text-xl font-semibold text-slate-900">{feature.title}</p>
<p className="mt-3 text-sm leading-7 text-slate-600">{feature.body}</p>
</div>
))}
</div>
</section>
<section className="mx-auto max-w-7xl px-6 py-10">
<div className="mb-8 max-w-2xl">
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-emerald-700">How the first delivery works</p>
<h2 className="mt-3 text-4xl font-bold tracking-tight text-slate-950">A real end-to-end workflow, not just a landing page</h2>
</div>
<div className="grid gap-6 lg:grid-cols-3">
{workflowSteps.map((item) => (
<div key={item.step} className="rounded-3xl border border-slate-100 bg-white p-6 shadow-sm">
<div className="inline-flex rounded-full bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
Step {item.step}
</div>
<p className="mt-4 text-2xl font-semibold text-slate-900">{item.title}</p>
<p className="mt-3 text-sm leading-7 text-slate-600">{item.body}</p>
</div>
))}
</div>
</section>
<section className="mx-auto max-w-7xl px-6 pb-16">
<div className="rounded-[2rem] bg-slate-950 px-8 py-10 text-white shadow-2xl">
<div className="grid gap-6 lg:grid-cols-[1.2fr,0.8fr] lg:items-center">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-emerald-300">Ready for the admin flow?</p>
<h2 className="mt-3 text-4xl font-bold tracking-tight">Go from public overview to the working app.</h2>
<p className="mt-4 max-w-2xl text-sm leading-7 text-slate-300">
Use the admin interface to register crops, log costs, review market predictions, manage notifications,
and continue shaping Ikiguzi into a field-ready product.
</p>
</div>
<BaseButtons type="justify-start" className="lg:justify-end">
<BaseButton href="/dashboard" color="info" label="Open admin interface" />
<BaseButton href="/login" color="whiteDark" label="Login" />
</BaseButtons>
</div>
</div>
</section>
</main>
</div>
</>
); );
} }
Starter.getLayout = function getLayout(page: ReactElement) { HomePage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>; return <LayoutGuest>{page}</LayoutGuest>;
}; };
export default HomePage;