diff --git a/apps/ottabase-template-app-tanstack/README.md b/apps/ottabase-template-app-tanstack/README.md index a141cedbd..b2d10e220 100644 --- a/apps/ottabase-template-app-tanstack/README.md +++ b/apps/ottabase-template-app-tanstack/README.md @@ -486,3 +486,15 @@ Ottabase now includes a dynamic marketing pages system with OttaORM CRUD + drag- - `/admin/pages` — list/create/duplicate/delete marketing pages - `/admin/pages/$pageId` — block builder with drag-and-drop reordering and inline editor - Public preview route in TanStack app: `/pages/$slug` + +Notes: + +- The admin pages list and builder consume OttaORM `useList()` hooks as arrays, while still tolerating legacy CRUD + payload wrappers (`{ data: [...] }` and `{ data: { data: [...] } }`) for list/detail/mutation payloads. +- The public marketing renderer (`/pages/$slug`) now mirrors the Next.js homepage variant family for `navbar`, `hero`, + `features`, `cta`, `footer`, and `about` slots. +- Duplicate and delete operations in the admin pages list/builder now use shadcn `AlertDialog` confirmations. +- Admin page builder now supports image selection from `@ottabase/medialibrary` for section media and feature images. +- Admin page builder includes a live preview panel with desktop/tablet/mobile viewport switching. +- Admin block editor includes an AI Copy Assistant powered by `@ottabase/cf-ai` with per-field generation plus bulk + Generate All and rewrite/shorten/expand actions for title/subtitle/body copy. diff --git a/apps/ottabase-template-app-tanstack/ottabase/db/schema.ts b/apps/ottabase-template-app-tanstack/ottabase/db/schema.ts index b260fa1f4..8011d8fd1 100644 --- a/apps/ottabase-template-app-tanstack/ottabase/db/schema.ts +++ b/apps/ottabase-template-app-tanstack/ottabase/db/schema.ts @@ -45,6 +45,7 @@ export { accountsTable, authenticatorsTable, mediaTable, sessionsTable, usersTab // APP-SPECIFIC TABLES // ============================================================ export { changelogEntriesTable } from '../models/ChangelogEntry'; +export { expenseGroupMembersTable, expenseGroupsTable, expensesTable, expenseSplitsTable } from '../models/Expense'; export { pageActionsTable, pageFeaturesTable, pagesTable, pageSectionsTable } from '../models/MarketingPage'; export { todosTable } from '../models/Todo'; diff --git a/apps/ottabase-template-app-tanstack/ottabase/db/schemas-helper.ts b/apps/ottabase-template-app-tanstack/ottabase/db/schemas-helper.ts index d3cde6c39..75904c7a8 100644 --- a/apps/ottabase-template-app-tanstack/ottabase/db/schemas-helper.ts +++ b/apps/ottabase-template-app-tanstack/ottabase/db/schemas-helper.ts @@ -34,6 +34,7 @@ import { } from '@ottabase/ottaorm'; import { getEnabledPackageTables } from '../config.migrations'; import { changelogEntriesTable } from '../models/ChangelogEntry'; +import { expenseGroupMembersTable, expenseGroupsTable, expensesTable, expenseSplitsTable } from '../models/Expense'; import { pageActionsTable, pageFeaturesTable, pagesTable, pageSectionsTable } from '../models/MarketingPage'; import { todosTable } from '../models/Todo'; @@ -67,6 +68,10 @@ export function getAllSchemas() { pageSectionsTable, pageFeaturesTable, pageActionsTable, + expenseGroupsTable, + expenseGroupMembersTable, + expensesTable, + expenseSplitsTable, todosTable, }; @@ -113,6 +118,10 @@ export function getSchemaSummary() { pageSectionsTable, pageFeaturesTable, pageActionsTable, + expenseGroupsTable, + expenseGroupMembersTable, + expensesTable, + expenseSplitsTable, todosTable, }; diff --git a/apps/ottabase-template-app-tanstack/ottabase/expenses/__tests__/naturalExpenseParser.test.ts b/apps/ottabase-template-app-tanstack/ottabase/expenses/__tests__/naturalExpenseParser.test.ts new file mode 100644 index 000000000..90ccf4675 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/ottabase/expenses/__tests__/naturalExpenseParser.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { parseNaturalExpenseInput } from '../naturalExpenseParser'; + +describe('parseNaturalExpenseInput', () => { + it('parses fixed amount plus equal remainder splits', () => { + const result = parseNaturalExpenseInput( + 'Osaka Haiku restaurant dinner on 29 may 10000 yen 5000 for Chris, rest shared equally between Kevin and dj', + { + now: new Date(2026, 2, 31), + knownMembers: ['Chris', 'Kevin', 'dj'], + }, + ); + + expect(result.description).toBe('Osaka Haiku restaurant dinner'); + expect(result.amount).toBe(10000); + expect(result.currency).toBe('JPY'); + expect(new Date(result.expenseDate).getFullYear()).toBe(2026); + expect(new Date(result.expenseDate).getMonth()).toBe(4); + expect(new Date(result.expenseDate).getDate()).toBe(29); + expect(result.splits).toEqual([ + expect.objectContaining({ memberName: 'Chris', amount: 5000, splitType: 'fixed' }), + expect.objectContaining({ memberName: 'Kevin', amount: 2500, splitType: 'equal' }), + expect.objectContaining({ memberName: 'dj', amount: 2500, splitType: 'equal' }), + ]); + }); + + it('distributes odd remainders deterministically', () => { + const result = parseNaturalExpenseInput( + 'Lunch 10001 yen 5000 for Chris, rest shared equally between Kevin and DJ', + { + now: new Date(2026, 2, 31), + }, + ); + + expect(result.splits.map((split) => split.amount)).toEqual([5000, 2501, 2500]); + }); + + it('throws when total amount is missing', () => { + expect(() => parseNaturalExpenseInput('Dinner for Chris')).toThrow('Could not find a total amount'); + }); + + it('extracts merchant names after an at phrase', () => { + const result = parseNaturalExpenseInput('The dinner at Osaka Haiku on 29 may 10000 yen', { + now: new Date(2026, 2, 31), + }); + + expect(result.merchant).toBe('Osaka Haiku'); + }); +}); diff --git a/apps/ottabase-template-app-tanstack/ottabase/expenses/naturalExpenseParser.ts b/apps/ottabase-template-app-tanstack/ottabase/expenses/naturalExpenseParser.ts new file mode 100644 index 000000000..4fc8c7d43 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/ottabase/expenses/naturalExpenseParser.ts @@ -0,0 +1,234 @@ +export interface ParsedExpenseSplit { + memberName: string; + amount: number; + splitType: 'fixed' | 'equal'; + note?: string; +} + +export interface ParsedExpenseInput { + description: string; + merchant?: string; + expenseDate: number; + amount: number; + currency: string; + paidByName?: string; + splits: ParsedExpenseSplit[]; + confidence: number; + warnings: string[]; +} + +interface ParserOptions { + now?: Date; + knownMembers?: string[]; +} + +const MONTHS: Record = { + jan: 0, + january: 0, + feb: 1, + february: 1, + mar: 2, + march: 2, + apr: 3, + april: 3, + may: 4, + jun: 5, + june: 5, + jul: 6, + july: 6, + aug: 7, + august: 7, + sep: 8, + sept: 8, + september: 8, + oct: 9, + october: 9, + nov: 10, + november: 10, + dec: 11, + december: 11, +}; + +const CURRENCY_ALIASES: Record = { + yen: 'JPY', + jpy: 'JPY', + usd: 'USD', + dollar: 'USD', + dollars: 'USD', + eur: 'EUR', + euro: 'EUR', + euros: 'EUR', + inr: 'INR', + rupee: 'INR', + rupees: 'INR', + gbp: 'GBP', + pound: 'GBP', + pounds: 'GBP', +}; + +const MIN_CONFIDENCE = 0.4; +const BASE_CONFIDENCE = 0.55; +const FIXED_SPLIT_CONFIDENCE_BOOST = 0.15; +const EQUAL_SPLIT_CONFIDENCE_BOOST = 0.2; +// The first few words usually contain the vendor/place before category words like "dinner". +const MERCHANT_WORD_LIMIT = 3; + +function normalizeName(name: string) { + return name.trim().replace(/\s+/g, ' '); +} + +function canonicalMemberName(name: string, knownMembers: string[]) { + const normalized = normalizeName(name); + const match = knownMembers.find((member) => member.toLowerCase() === normalized.toLowerCase()); + return match || normalized.replace(/^./, (char) => char.toUpperCase()); +} + +function parseIntegerAmount(value: string) { + return Math.round(Number(value.replace(/,/g, ''))); +} + +function parseDate(input: string, now: Date) { + const lower = input.toLowerCase(); + const dateWithMonth = lower.match(/\bon\s+(\d{1,2})(?:st|nd|rd|th)?\s+([a-z]+)(?:\s+(\d{4}))?\b/); + if (dateWithMonth) { + const day = Number(dateWithMonth[1]); + const month = MONTHS[dateWithMonth[2]]; + const year = dateWithMonth[3] ? Number(dateWithMonth[3]) : now.getFullYear(); + if (month !== undefined && day >= 1 && day <= 31) { + return new Date(year, month, day).getTime(); + } + } + + const isoDate = lower.match(/\bon\s+(\d{4})-(\d{1,2})-(\d{1,2})\b/); + if (isoDate) { + return new Date(Number(isoDate[1]), Number(isoDate[2]) - 1, Number(isoDate[3])).getTime(); + } + + if (/\byesterday\b/.test(lower)) { + const date = new Date(now); + date.setDate(date.getDate() - 1); + return date.getTime(); + } + + if (/\btoday\b/.test(lower)) { + return now.getTime(); + } + + return now.getTime(); +} + +function removeParsedFragments(input: string) { + return input + .replace(/\bon\s+\d{1,2}(?:st|nd|rd|th)?\s+[a-z]+(?:\s+\d{4})?\b/gi, ' ') + .replace(/\bon\s+\d{4}-\d{1,2}-\d{1,2}\b/gi, ' ') + .replace(/\b(today|yesterday)\b/gi, ' ') + .replace(/\b\d[\d,]*(?:\.\d+)?\s*(yen|jpy|usd|dollars?|eur|euros?|inr|rupees?|gbp|pounds?)\b/gi, ' ') + .replace(/\b\d[\d,]*\s+(?:for|to)\s+[a-z][a-z .'-]*\b/gi, ' ') + .replace(/\b(rest|remainder|remaining)\s+(?:is\s+)?(?:shared\s+)?equally\s+(?:between|among|with)\s+.+$/i, ' ') + .replace(/\bpaid\s+by\s+[a-z][a-z .'-]*\b/gi, ' ') + .replace(/\s+[,.]+/g, ' ') + .replace(/[,.]+$/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +function splitNames(value: string, knownMembers: string[]) { + return value + .split(/,|\band\b|\+/i) + .map((name) => name.replace(/\b(rest|remainder|remaining|shared|equally|between|among|with)\b/gi, '')) + .map(normalizeName) + .filter(Boolean) + .map((name) => canonicalMemberName(name, knownMembers)); +} + +function extractMerchant(description: string) { + const locationMatch = description.match(/\bat\s+(.+)$/i); + const source = locationMatch?.[1] || description; + return source.split(/\s+/).slice(0, MERCHANT_WORD_LIMIT).join(' '); +} + +export function parseNaturalExpenseInput(input: string, options: ParserOptions = {}): ParsedExpenseInput { + const now = options.now || new Date(); + const knownMembers = options.knownMembers || []; + const warnings: string[] = []; + const normalizedInput = input.trim(); + const lower = normalizedInput.toLowerCase(); + + const amountMatch = lower.match( + /\b(\d[\d,]*(?:\.\d+)?)\s*(yen|jpy|usd|dollars?|eur|euros?|inr|rupees?|gbp|pounds?)\b/, + ); + if (!amountMatch) { + throw new Error('Could not find a total amount and currency. Try: "10000 yen".'); + } + + const amount = parseIntegerAmount(amountMatch[1]); + const currency = CURRENCY_ALIASES[amountMatch[2]] || amountMatch[2].toUpperCase(); + const expenseDate = parseDate(normalizedInput, now); + + const fixedSplits: ParsedExpenseSplit[] = []; + const fixedRegex = + /\b(\d[\d,]*)\s+(?:for|to)\s+([a-z][a-z .'-]*?)(?=\s*,|\s+and\s+\d|\s+rest\b|\s+remainder\b|\s+remaining\b|$)/gi; + let fixedMatch: RegExpExecArray | null; + while ((fixedMatch = fixedRegex.exec(normalizedInput)) !== null) { + fixedSplits.push({ + memberName: canonicalMemberName(fixedMatch[2], knownMembers), + amount: parseIntegerAmount(fixedMatch[1]), + splitType: 'fixed', + note: 'Fixed amount from natural language input', + }); + } + + const equalNamesMatch = normalizedInput.match( + /\b(?:rest|remainder|remaining)\s+(?:is\s+)?(?:shared\s+)?equally\s+(?:between|among|with)\s+(.+)$/i, + ); + const equalNames = equalNamesMatch ? splitNames(equalNamesMatch[1], knownMembers) : []; + + const fixedTotal = fixedSplits.reduce((sum, split) => sum + split.amount, 0); + const remaining = amount - fixedTotal; + if (remaining < 0) { + throw new Error('Fixed split amounts exceed the total expense amount.'); + } + + const equalSplits: ParsedExpenseSplit[] = []; + if (equalNames.length > 0) { + const base = Math.floor(remaining / equalNames.length); + const remainder = remaining % equalNames.length; + equalNames.forEach((memberName, index) => { + // Keep integer currency units by assigning leftover units to earlier names deterministically. + equalSplits.push({ + memberName, + amount: base + (index < remainder ? 1 : 0), + splitType: 'equal', + note: 'Equal share of remaining amount', + }); + }); + } else if (remaining > 0) { + warnings.push('No equal-share members were found for the remaining amount.'); + } + + const paidByMatch = normalizedInput.match(/\bpaid\s+by\s+([a-z][a-z .'-]*?)(?=\s*,|\s+on\b|$)/i); + const paidByName = paidByMatch ? canonicalMemberName(paidByMatch[1], knownMembers) : undefined; + const description = removeParsedFragments(normalizedInput) || 'Expense'; + const merchant = extractMerchant(description); + const confidence = Math.max( + MIN_CONFIDENCE, + Math.min( + 1, + BASE_CONFIDENCE + + (fixedSplits.length ? FIXED_SPLIT_CONFIDENCE_BOOST : 0) + + (equalSplits.length ? EQUAL_SPLIT_CONFIDENCE_BOOST : 0), + ), + ); + + return { + description, + merchant, + expenseDate, + amount, + currency, + paidByName, + splits: [...fixedSplits, ...equalSplits], + confidence, + warnings, + }; +} diff --git a/apps/ottabase-template-app-tanstack/ottabase/models/Expense.schema.ts b/apps/ottabase-template-app-tanstack/ottabase/models/Expense.schema.ts new file mode 100644 index 000000000..23a09e936 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/ottabase/models/Expense.schema.ts @@ -0,0 +1,84 @@ +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; + +export const expenseGroupsTable = sqliteTable('expense_groups', { + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + appId: text('app_id').notNull(), + organizationId: text('organization_id'), + userId: text('user_id'), + name: text('name').notNull(), + currency: text('currency').notNull().default('JPY'), + createdAt: integer('created_at') + .$defaultFn(() => Date.now()) + .notNull(), + updatedAt: integer('updated_at') + .$defaultFn(() => Date.now()) + .$onUpdateFn(() => Date.now()) + .notNull(), +}); + +export const expenseGroupMembersTable = sqliteTable('expense_group_members', { + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + groupId: text('group_id').notNull(), + appId: text('app_id').notNull(), + organizationId: text('organization_id'), + userId: text('user_id'), + name: text('name').notNull(), + createdAt: integer('created_at') + .$defaultFn(() => Date.now()) + .notNull(), + updatedAt: integer('updated_at') + .$defaultFn(() => Date.now()) + .$onUpdateFn(() => Date.now()) + .notNull(), +}); + +export const expensesTable = sqliteTable('expenses', { + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + groupId: text('group_id').notNull(), + appId: text('app_id').notNull(), + organizationId: text('organization_id'), + userId: text('user_id'), + description: text('description').notNull(), + merchant: text('merchant'), + expenseDate: integer('expense_date').notNull(), + amount: integer('amount').notNull(), + currency: text('currency').notNull().default('JPY'), + paidByMemberId: text('paid_by_member_id'), + rawInput: text('raw_input'), + createdAt: integer('created_at') + .$defaultFn(() => Date.now()) + .notNull(), + updatedAt: integer('updated_at') + .$defaultFn(() => Date.now()) + .$onUpdateFn(() => Date.now()) + .notNull(), +}); + +export const expenseSplitsTable = sqliteTable('expense_splits', { + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + expenseId: text('expense_id').notNull(), + groupId: text('group_id'), + memberId: text('member_id').notNull(), + appId: text('app_id').notNull(), + organizationId: text('organization_id'), + userId: text('user_id'), + amount: integer('amount').notNull(), + splitType: text('split_type').notNull().default('equal'), + note: text('note'), + createdAt: integer('created_at') + .$defaultFn(() => Date.now()) + .notNull(), +}); + +export type ExpenseGroupRow = typeof expenseGroupsTable.$inferSelect; +export type ExpenseGroupMemberRow = typeof expenseGroupMembersTable.$inferSelect; +export type ExpenseRow = typeof expensesTable.$inferSelect; +export type ExpenseSplitRow = typeof expenseSplitsTable.$inferSelect; diff --git a/apps/ottabase-template-app-tanstack/ottabase/models/Expense.ts b/apps/ottabase-template-app-tanstack/ottabase/models/Expense.ts new file mode 100644 index 000000000..b1ef67da5 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/ottabase/models/Expense.ts @@ -0,0 +1,115 @@ +import { BaseModel, type ModelFields, type PackageType } from '@ottabase/ottaorm'; +import { expenseGroupMembersTable, expenseGroupsTable, expensesTable, expenseSplitsTable } from './Expense.schema'; + +export { + expenseGroupMembersTable, + expenseGroupsTable, + expensesTable, + expenseSplitsTable, + type ExpenseGroupMemberRow, + type ExpenseGroupRow, + type ExpenseRow, + type ExpenseSplitRow, +} from './Expense.schema'; + +const tenantFields: Pick = { + appId: { type: 'string', editable: true, filterable: true }, + organizationId: { type: 'string', editable: true, filterable: true }, + userId: { type: 'string', editable: true, filterable: true }, +}; + +export class ExpenseGroup extends BaseModel { + static entity = 'expense_groups'; + static table = expenseGroupsTable; + static primaryKey = 'id'; + static packageName = 'app'; + static packageType: PackageType = 'app'; + + protected static fields: ModelFields = { + id: { type: 'id', primaryKey: true, editable: false }, + ...tenantFields, + name: { type: 'string', editable: true, searchable: true, sortable: true }, + currency: { type: 'string', editable: true, filterable: true }, + createdAt: { type: 'date', editable: false, sortable: true }, + updatedAt: { type: 'date', editable: false, sortable: true }, + }; + + protected static validationRules = { + name: { rules: 'required|max:100', fieldName: 'Name' }, + }; +} + +export class ExpenseGroupMember extends BaseModel { + static entity = 'expense_group_members'; + static table = expenseGroupMembersTable; + static primaryKey = 'id'; + static packageName = 'app'; + static packageType: PackageType = 'app'; + + protected static fields: ModelFields = { + id: { type: 'id', primaryKey: true, editable: false }, + groupId: { type: 'string', editable: true, filterable: true }, + ...tenantFields, + name: { type: 'string', editable: true, searchable: true, sortable: true }, + createdAt: { type: 'date', editable: false, sortable: true }, + updatedAt: { type: 'date', editable: false, sortable: true }, + }; + + protected static validationRules = { + groupId: { rules: 'required', fieldName: 'Group' }, + name: { rules: 'required|max:80', fieldName: 'Name' }, + }; +} + +export class Expense extends BaseModel { + static entity = 'expenses'; + static table = expensesTable; + static primaryKey = 'id'; + static packageName = 'app'; + static packageType: PackageType = 'app'; + + protected static fields: ModelFields = { + id: { type: 'id', primaryKey: true, editable: false }, + groupId: { type: 'string', editable: true, filterable: true }, + ...tenantFields, + description: { type: 'string', editable: true, searchable: true, sortable: true }, + merchant: { type: 'string', editable: true, searchable: true }, + expenseDate: { type: 'date', editable: true, sortable: true }, + amount: { type: 'number', editable: true, sortable: true }, + currency: { type: 'string', editable: true, filterable: true }, + paidByMemberId: { type: 'string', editable: true, filterable: true }, + rawInput: { type: 'string', editable: true }, + createdAt: { type: 'date', editable: false, sortable: true }, + updatedAt: { type: 'date', editable: false, sortable: true }, + }; + + protected static validationRules = { + groupId: { rules: 'required', fieldName: 'Group' }, + description: { rules: 'required|max:200', fieldName: 'Description' }, + }; +} + +export class ExpenseSplit extends BaseModel { + static entity = 'expense_splits'; + static table = expenseSplitsTable; + static primaryKey = 'id'; + static packageName = 'app'; + static packageType: PackageType = 'app'; + + protected static fields: ModelFields = { + id: { type: 'id', primaryKey: true, editable: false }, + expenseId: { type: 'string', editable: true, filterable: true }, + groupId: { type: 'string', editable: true, filterable: true }, + memberId: { type: 'string', editable: true, filterable: true }, + ...tenantFields, + amount: { type: 'number', editable: true, sortable: true }, + splitType: { type: 'string', editable: true, filterable: true }, + note: { type: 'string', editable: true }, + createdAt: { type: 'date', editable: false, sortable: true }, + }; + + protected static validationRules = { + expenseId: { rules: 'required', fieldName: 'Expense' }, + memberId: { rules: 'required', fieldName: 'Member' }, + }; +} diff --git a/apps/ottabase-template-app-tanstack/ottabase/models/MarketingPage.schema.ts b/apps/ottabase-template-app-tanstack/ottabase/models/MarketingPage.schema.ts index 93a2422da..cfdded059 100644 --- a/apps/ottabase-template-app-tanstack/ottabase/models/MarketingPage.schema.ts +++ b/apps/ottabase-template-app-tanstack/ottabase/models/MarketingPage.schema.ts @@ -36,6 +36,8 @@ export const pageSectionsTable = sqliteTable('page_sections', { title: text('title'), subtitle: text('subtitle'), body: text('body'), + mediaUrl: text('media_url'), + mediaAlt: text('media_alt'), enabled: integer('enabled', { mode: 'boolean' }).default(true).notNull(), sortOrder: integer('sort_order').notNull().default(0), createdAt: integer('created_at') @@ -59,6 +61,8 @@ export const pageFeaturesTable = sqliteTable('page_features', { description: text('description'), icon: text('icon'), link: text('link'), + mediaUrl: text('media_url'), + mediaAlt: text('media_alt'), sortOrder: integer('sort_order').notNull().default(0), createdAt: integer('created_at') .$defaultFn(() => Date.now()) diff --git a/apps/ottabase-template-app-tanstack/ottabase/models/MarketingPage.ts b/apps/ottabase-template-app-tanstack/ottabase/models/MarketingPage.ts index edcf6e29a..7e07c6181 100644 --- a/apps/ottabase-template-app-tanstack/ottabase/models/MarketingPage.ts +++ b/apps/ottabase-template-app-tanstack/ottabase/models/MarketingPage.ts @@ -64,6 +64,8 @@ export class PageSection extends BaseModel { title: { type: 'string', editable: true, searchable: true }, subtitle: { type: 'string', editable: true }, body: { type: 'string', editable: true }, + mediaUrl: { type: 'string', editable: true }, + mediaAlt: { type: 'string', editable: true }, enabled: { type: 'boolean', editable: true, filterable: true }, sortOrder: { type: 'number', editable: true, sortable: true }, createdAt: { type: 'date', editable: false, sortable: true }, @@ -88,6 +90,8 @@ export class PageFeature extends BaseModel { description: { type: 'string', editable: true }, icon: { type: 'string', editable: true }, link: { type: 'string', editable: true }, + mediaUrl: { type: 'string', editable: true }, + mediaAlt: { type: 'string', editable: true }, sortOrder: { type: 'number', editable: true, sortable: true }, createdAt: { type: 'date', editable: false, sortable: true }, }; diff --git a/apps/ottabase-template-app-tanstack/package.json b/apps/ottabase-template-app-tanstack/package.json index e87809193..84ddbcf19 100644 --- a/apps/ottabase-template-app-tanstack/package.json +++ b/apps/ottabase-template-app-tanstack/package.json @@ -21,6 +21,9 @@ }, "dependencies": { "@auth/core": "catalog:", + "@dnd-kit/core": "6.3.1", + "@dnd-kit/sortable": "10.0.0", + "@dnd-kit/utilities": "3.2.2", "@hookform/resolvers": "catalog:", "@mantine/carousel": "catalog:", "@mantine/core": "catalog:", diff --git a/apps/ottabase-template-app-tanstack/src/hooks/expenseHooks.ts b/apps/ottabase-template-app-tanstack/src/hooks/expenseHooks.ts new file mode 100644 index 000000000..f2c02574f --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/hooks/expenseHooks.ts @@ -0,0 +1,33 @@ +import { api } from '@/lib/api'; +import type { + ExpenseGroupMemberRecord, + ExpenseGroupRecord, + ExpenseRecord, + ExpenseSplitRecord, + NaturalExpenseResponse, +} from '@/types/expenses'; +import { createModelHooks } from '@ottabase/ottaorm/client'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +export const expenseGroupHooks = createModelHooks({ entityName: 'expense_groups' }); +export const expenseMemberHooks = createModelHooks({ entityName: 'expense_group_members' }); +export const expenseHooks = createModelHooks({ entityName: 'expenses' }); +export const expenseSplitHooks = createModelHooks({ entityName: 'expense_splits' }); + +export function useCreateNaturalExpense(groupId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: string) => + api(`/api/expense-groups/${encodeURIComponent(groupId)}/natural-expense`, { + method: 'POST', + body: { input }, + }), + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['expenses'] }), + queryClient.invalidateQueries({ queryKey: ['expense_splits'] }), + queryClient.invalidateQueries({ queryKey: ['expense_group_members'] }), + ]); + }, + }); +} diff --git a/apps/ottabase-template-app-tanstack/src/pages/admin/pages/ActionListEditor.tsx b/apps/ottabase-template-app-tanstack/src/pages/admin/pages/ActionListEditor.tsx new file mode 100644 index 000000000..81b7b9d10 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/pages/admin/pages/ActionListEditor.tsx @@ -0,0 +1,189 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Button, + Input, + Label, + Switch, +} from '@ottabase/ui-shadcn'; +import { Plus, Trash2 } from 'lucide-react'; +import { useRef, useState } from 'react'; + +interface ActionItem { + id: string; + label: string; + href: string; + variant: string; + icon?: string; + external: boolean; + sortOrder: number; +} + +interface ActionListEditorProps { + actions: ActionItem[]; + onAdd: () => void; + onUpdate: (id: string, data: Partial) => void; + onDelete: (id: string) => void; + isPending?: boolean; +} + +const ACTION_VARIANTS = ['primary', 'secondary', 'outline', 'ghost', 'link'] as const; + +/** Single action row with controlled state. */ +function ActionRow({ + action, + onUpdate, + onDelete, +}: { + action: ActionItem; + onUpdate: (id: string, data: Partial) => void; + onDelete: (id: string) => void; +}) { + const [local, setLocal] = useState(action); + const [expanded, setExpanded] = useState(false); + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); + const localRef = useRef(local); + localRef.current = local; + + const commit = () => { + const current = localRef.current; + const changed: Partial = {}; + if (current.label !== action.label) changed.label = current.label; + if (current.href !== action.href) changed.href = current.href; + if (current.variant !== action.variant) changed.variant = current.variant; + if (current.icon !== action.icon) changed.icon = current.icon; + if (current.external !== action.external) changed.external = current.external; + if (Object.keys(changed).length > 0) { + onUpdate(action.id, changed); + } + }; + + return ( +
+
+ setLocal({ ...local, label: e.target.value })} + onBlur={commit} + /> + setLocal({ ...local, href: e.target.value })} + onBlur={commit} + /> + + +
+ {expanded && ( +
+
+
+ + +
+
+ + setLocal({ ...local, icon: e.target.value })} + onBlur={commit} + /> +
+
+
+ + { + setLocal({ ...local, external: val }); + onUpdate(action.id, { external: val }); + }} + /> +
+
+ )} + + + + + Delete Action? + + This will permanently remove {action.label || 'this action'}. + + + + Cancel + { + onDelete(action.id); + setConfirmDeleteOpen(false); + }} + > + Delete + + + + +
+ ); +} + +/** Managed list of actions for a section. */ +export function ActionListEditor({ actions, onAdd, onUpdate, onDelete, isPending }: ActionListEditorProps) { + return ( +
+
+

Actions

+ +
+ {actions.length === 0 && ( +

No actions. Add one above.

+ )} + {actions.map((action) => ( + + ))} +
+ ); +} diff --git a/apps/ottabase-template-app-tanstack/src/pages/admin/pages/AdminPageBuilderPage.tsx b/apps/ottabase-template-app-tanstack/src/pages/admin/pages/AdminPageBuilderPage.tsx index 3d74525c5..7e6a2e95d 100644 --- a/apps/ottabase-template-app-tanstack/src/pages/admin/pages/AdminPageBuilderPage.tsx +++ b/apps/ottabase-template-app-tanstack/src/pages/admin/pages/AdminPageBuilderPage.tsx @@ -1,127 +1,229 @@ -import { actionHooks, pageHooks, sectionHooks, useBlocksRegistry, featureHooks } from '@/hooks/marketingPageHooks'; +import { actionHooks, featureHooks, pageHooks, sectionHooks, useBlocksRegistry } from '@/hooks/marketingPageHooks'; import { globalStore, organizationIdAtom, userAtom } from '@/ottabase/state/appState'; -import { - Badge, - Button, - Card, - CardContent, - CardHeader, - CardTitle, - Input, - Label, - Switch, - Textarea, -} from '@ottabase/ui-shadcn'; +import { Badge, Button, Card, CardContent, Input, Label } from '@ottabase/ui-shadcn'; import { Link, useParams } from '@tanstack/react-router'; -import { GripVertical, Plus, Save, Trash2 } from 'lucide-react'; -import { useEffect, useMemo, useState } from 'react'; +import { Monitor, RefreshCw, Smartphone, Tablet, Save } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; - -type EditableBlock = { - id: string; - title?: string; - subtitle?: string; - body?: string; - variant?: string; - enabled?: boolean; -}; +import { BlockEditor } from './BlockEditor'; +import { BlockPalette } from './BlockPalette'; +import { BuilderCanvas } from './BuilderCanvas'; +import { extractCrudDetailRecord, normalizeCrudListPayload } from './crudPayload'; +import type { BlockDefinition, EditableBlock, PageDraft } from './builder-types'; export function AdminPageBuilderPage() { const { pageId } = useParams({ from: '/admin/pages/$pageId' }); const [selectedId, setSelectedId] = useState(null); - const [draft, setDraft] = useState(null); - const [pageDraft, setPageDraft] = useState<{ id: string; title: string; slug: string; status: string } | null>( - null, - ); + const [pageDraft, setPageDraft] = useState(null); + const [previewDevice, setPreviewDevice] = useState<'desktop' | 'tablet' | 'mobile'>('desktop'); + const [previewRefreshKey, setPreviewRefreshKey] = useState(Date.now()); const organizationId = globalStore.get(organizationIdAtom) || null; const userId = globalStore.get(userAtom)?.id || null; + // --- Data queries --- const pageQuery = pageHooks.useDetail(pageId); - const sectionList = sectionHooks.useList({ filters: { pageId } as any }); + const sectionList = sectionHooks.useList({ where: { pageId } as any }); const registry = useBlocksRegistry(); const createSection = sectionHooks.useCreate(); const updateSection = sectionHooks.useUpdate(); const deleteSection = sectionHooks.useDelete(); - const featureList = featureHooks.useList({ filters: { sectionId: selectedId || '' } as any }); + const featureList = featureHooks.useList({ where: { sectionId: selectedId || '' } as any }); const createFeature = featureHooks.useCreate(); const updateFeature = featureHooks.useUpdate(); const deleteFeature = featureHooks.useDelete(); - const actionList = actionHooks.useList({ filters: { sectionId: selectedId || '' } as any }); + const actionList = actionHooks.useList({ where: { sectionId: selectedId || '' } as any }); const createAction = actionHooks.useCreate(); const updateAction = actionHooks.useUpdate(); const deleteAction = actionHooks.useDelete(); const updatePage = pageHooks.useUpdate(); + const bumpPreview = useCallback(() => { + setPreviewRefreshKey(Date.now()); + }, []); + + // --- Derived data --- const sections = useMemo(() => { - const rows = (sectionList.data?.data ?? []) as any[]; + const rows = normalizeCrudListPayload(sectionList.data); return [...rows].sort((a, b) => Number(a.sortOrder) - Number(b.sortOrder)); - }, [sectionList.data?.data]); + }, [sectionList.data]); + + const selected = sections.find((s) => s.id === selectedId) ?? null; - const selected = sections.find((section) => section.id === selectedId) ?? null; const selectedFeatures = useMemo(() => { - const rows = (featureList.data?.data ?? []) as any[]; + const rows = normalizeCrudListPayload(featureList.data); return [...rows].sort((a, b) => Number(a.sortOrder) - Number(b.sortOrder)); - }, [featureList.data?.data]); + }, [featureList.data]); + const selectedActions = useMemo(() => { - const rows = (actionList.data?.data ?? []) as any[]; + const rows = normalizeCrudListPayload(actionList.data); return [...rows].sort((a, b) => Number(a.sortOrder) - Number(b.sortOrder)); - }, [actionList.data?.data]); + }, [actionList.data]); - useEffect(() => { - if (!selected) { - setDraft(null); - return; - } - setDraft({ - id: selected.id, - title: selected.title, - subtitle: selected.subtitle, - body: selected.body, - variant: selected.variant, - enabled: selected.enabled, - }); - }, [selected?.id]); + const registryBlocks: BlockDefinition[] = registry.data?.blocks ?? []; + // --- Sync page draft --- useEffect(() => { - const page = (pageQuery.data as any)?.data; + const page = extractCrudDetailRecord(pageQuery.data); if (!page) return; - setPageDraft({ - id: page.id, - title: page.title || '', - slug: page.slug || '', - status: page.status || 'draft', + setPageDraft({ id: page.id, title: page.title || '', slug: page.slug || '', status: page.status || 'draft' }); + }, [pageQuery.data]); + + // --- Handlers --- + const handleAddBlock = useCallback( + async (block: BlockDefinition) => { + await createSection.mutateAsync({ + pageId, + appId: 'ottabase-template-app', + organizationId, + userId, + slot: block.id, + variant: block.variants[0]?.id || 'default', + title: block.label, + enabled: true, + sortOrder: sections.length, + }); + toast.success(`${block.label} added`); + await sectionList.refetch(); + bumpPreview(); + }, + [pageId, organizationId, userId, sections.length, createSection, sectionList, bumpPreview], + ); + + const handleReorder = useCallback( + async (activeId: string, overId: string) => { + const ordered = [...sections]; + const from = ordered.findIndex((r) => r.id === activeId); + const to = ordered.findIndex((r) => r.id === overId); + if (from < 0 || to < 0) return; + const [moved] = ordered.splice(from, 1); + ordered.splice(to, 0, moved); + await Promise.all( + ordered.map((section, index) => + updateSection.mutateAsync({ + id: section.id, + data: { sortOrder: index }, + }), + ), + ); + toast.success('Blocks reordered'); + await sectionList.refetch(); + bumpPreview(); + }, + [sections, updateSection, sectionList, bumpPreview], + ); + + const handleSaveBlock = useCallback( + async (draft: EditableBlock) => { + await updateSection.mutateAsync({ + id: draft.id, + data: { + title: draft.title, + subtitle: draft.subtitle, + body: draft.body, + mediaUrl: draft.mediaUrl, + mediaAlt: draft.mediaAlt, + variant: draft.variant, + enabled: draft.enabled, + }, + }); + toast.success('Block saved'); + await sectionList.refetch(); + bumpPreview(); + }, + [updateSection, sectionList, bumpPreview], + ); + + const handleDeleteBlock = useCallback( + async (id: string) => { + await deleteSection.mutateAsync(id); + setSelectedId(null); + toast.success('Block deleted'); + await sectionList.refetch(); + bumpPreview(); + }, + [deleteSection, sectionList, bumpPreview], + ); + + const handleAddFeature = useCallback(async () => { + if (!selected) return; + await createFeature.mutateAsync({ + sectionId: selected.id, + appId: 'ottabase-template-app', + organizationId, + userId, + title: `Feature ${selectedFeatures.length + 1}`, + description: '', + sortOrder: selectedFeatures.length, + }); + await featureList.refetch(); + bumpPreview(); + }, [selected, organizationId, userId, selectedFeatures.length, createFeature, featureList, bumpPreview]); + + const handleUpdateFeature = useCallback( + async (id: string, data: Record) => { + await updateFeature.mutateAsync({ id, data }); + await featureList.refetch(); + bumpPreview(); + }, + [updateFeature, featureList, bumpPreview], + ); + + const handleDeleteFeature = useCallback( + async (id: string) => { + await deleteFeature.mutateAsync(id); + await featureList.refetch(); + bumpPreview(); + }, + [deleteFeature, featureList, bumpPreview], + ); + + const handleAddAction = useCallback(async () => { + if (!selected) return; + await createAction.mutateAsync({ + sectionId: selected.id, + appId: 'ottabase-template-app', + organizationId, + userId, + label: `Action ${selectedActions.length + 1}`, + href: '/signup', + variant: 'primary', + external: false, + sortOrder: selectedActions.length, }); - }, [(pageQuery.data as any)?.data?.id, (pageQuery.data as any)?.data?.updatedAt]); - - const reorder = async (dragId: string, dropId: string) => { - if (dragId === dropId) return; - const ordered = [...sections]; - const from = ordered.findIndex((row) => row.id === dragId); - const to = ordered.findIndex((row) => row.id === dropId); - if (from < 0 || to < 0) return; - - const [moved] = ordered.splice(from, 1); - ordered.splice(to, 0, moved); - - await Promise.all( - ordered.map((section, index) => - updateSection.mutateAsync({ - id: section.id, - sortOrder: index, - }), - ), - ); - toast.success('Blocks reordered'); - await sectionList.refetch(); - }; + await actionList.refetch(); + bumpPreview(); + }, [selected, organizationId, userId, selectedActions.length, createAction, actionList, bumpPreview]); + + const handleUpdateAction = useCallback( + async (id: string, data: Record) => { + await updateAction.mutateAsync({ id, data }); + await actionList.refetch(); + bumpPreview(); + }, + [updateAction, actionList, bumpPreview], + ); + + const handleDeleteAction = useCallback( + async (id: string) => { + await deleteAction.mutateAsync(id); + await actionList.refetch(); + bumpPreview(); + }, + [deleteAction, actionList, bumpPreview], + ); + + const previewWidthClass = + previewDevice === 'mobile' ? 'w-[390px]' : previewDevice === 'tablet' ? 'w-[768px]' : 'w-full'; + const previewUrl = `/pages/${encodeURIComponent(pageDraft?.slug || '')}?preview=true&_k=${previewRefreshKey}`; return (
+ {/* Header */}
@@ -129,57 +231,67 @@ export function AdminPageBuilderPage() {

{pageDraft?.title || 'Page Builder'}

- End-to-end builder with sortable blocks, features and actions. + Drag-and-drop builder with sortable blocks, features and actions.

+ {/* Page settings bar */}
- setPageDraft((prev) => (prev ? { ...prev, title: event.target.value } : prev)) - } + onChange={(e) => setPageDraft((p) => (p ? { ...p, title: e.target.value } : p))} />
- setPageDraft((prev) => (prev ? { ...prev, slug: event.target.value } : prev)) - } + onChange={(e) => setPageDraft((p) => (p ? { ...p, slug: e.target.value } : p))} />
- ))} -
-
- - - - Canvas - - - {sections.map((section) => ( -
event.dataTransfer.setData('text/plain', section.id)} - onDragOver={(event) => event.preventDefault()} - onDrop={async (event) => { - event.preventDefault(); - const dragId = event.dataTransfer.getData('text/plain'); - await reorder(dragId, section.id); - }} - onClick={() => setSelectedId(section.id)} - className={`cursor-pointer rounded-md border p-3 ${selectedId === section.id ? 'border-primary' : ''}`} + -
-
- ))} - - - - - - Block Editor - - - {!selected || !draft ? ( -

Select a block from the canvas to edit.

- ) : ( - <> -
- - setDraft({ ...draft, title: event.target.value })} - /> -
-
- - setDraft({ ...draft, subtitle: event.target.value })} - /> -
-
- -