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 openaiRoutes = require('./routes/openai');
const ikiguziRoutes = require('./routes/ikiguzi');
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/ikiguzi', passport.authenticate('jwt', { session: false }), ikiguziRoutes);
app.use(
'/api/openai',
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 { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon'

View File

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

View File

@ -7,6 +7,11 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiViewDashboardOutline,
label: 'Dashboard',
},
{
href: '/farm-workspace',
icon: icon.mdiChartTimelineVariant,
label: 'Farm workspace',
},
{
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 Head from 'next/head';
import Link from 'next/link';
import React from 'react';
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 LayoutGuest from '../layouts/Guest';
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 [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
const [contentType, setContentType] = useState('video');
const [contentPosition, setContentPosition] = useState('right');
const textColor = useAppSelector((state) => state.style.linkColor);
const title = 'Ikiguzi Price Tracker'
// 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 (
<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>)
}
};
const workflowSteps = [
{
step: '01',
title: 'Register a crop',
body: 'Set up the crop, region, season, and area so every cost and prediction has a real farming context.',
},
{
step: '02',
title: 'Log costs fast',
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.',
},
];
function HomePage() {
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>
<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>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
>
{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 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">
<header className="sticky top-0 z-20 border-b border-white/70 bg-white/80 backdrop-blur">
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
<div>
<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>
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
<BaseButtons type="justify-end" noWrap>
<BaseButton href="/dashboard" color="whiteDark" label="Admin interface" />
<BaseButton href="/login" color="info" label="Login" />
</BaseButtons>
</CardBox>
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
</div>
</header>
</div>
<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>
<h1 className="max-w-4xl text-5xl font-bold leading-tight tracking-tight text-slate-950 md:text-6xl">
Turn daily farm expenses into clear, confident selling decisions.
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-600">
Ikiguzi combines crop cost tracking, market monitoring, and simple AI guidance so farmers can understand
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 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 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>;
};
export default HomePage;