Skip to content

Commit 5f3dcfe

Browse files
Make the flagship reachable, and safe to trust with a client list
A market-readiness audit found the product sold a retention radar and delivered a content generator: every "Show me who is slipping" button routed to an 11-field content-marketing onboarding ending in "Generate my content preview", and the only door to the radar was a localStorage-gated block on /preview that vanished on reload and did not exist on a second device. On a phone the flagship did not exist at all. This fixes that, and three things a real owner would have hit. Funnel. Signup is one step and five fields (name, type, city, your name, email), and it lands on the radar, not a content preview. Description and brand voice are no longer collected up front; they are content-only and asked for later. The empty radar leads with "See it with sample data", a one-click server-side import, so an owner with no CSV handy sees the thing populated immediately. Trust. The win-back no longer sends on one click. It drafts, stores the note, and shows the owner exactly what will go out; a second, deliberate "Send it" delivers it. This is the whole point of a product that emails your client list, and it makes the FAQ's "you see everything the agent writes" true. Discard and Rewrite are there too. Deletion. There was no delete path anywhere, while /privacy promised an owner could delete their data at any time. There is now a token-gated route that cancels any subscription and removes every row in one transaction, a type-DELETE-to-confirm control in the dashboard, and a privacy policy rewritten to describe the client list it actually holds (controller/processor, CASL, self-serve deletion). Honest numbers. The landing page claimed a regular is worth $1,200-$3,000 a year; the cited source is a three-year loyalty-member figure, about $600 annualized, so it now says that. "40% of new clients" was the wrong population (40% is the whole base), corrected. The source line no longer calls vendor blogs "research". Also: the logo is one shared BloomMark instead of the emerald app-icon that still sat on setup, dashboard, preview and the legal pages; a booking export's cancelled and no-show rows are no longer counted as visits; and an over-cap import keeps the most recently active clients instead of an arbitrary slice.
1 parent ff0c770 commit 5f3dcfe

19 files changed

Lines changed: 707 additions & 660 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
**Bloom finds the clients a local business is about to lose, and writes to each one personally.**
44

5-
Nobody cancels. They just quietly stop coming, and the owner finds out months later, if ever. Roughly 40% of new clients are gone within a year, a first-timer who does not rebook within 30 days has about a one-in-five chance of ever returning, and a regular is worth $1,200 to $3,000 a year. The signal is invisible because it is an *absence*, spread across hundreds of people who each have their own rhythm.
5+
Nobody cancels. They just quietly stop coming, and the owner finds out months later, if ever. A typical salon loses about 40% of its clients every year, a first-timer who does not rebook within 30 days has about a one-in-five chance of ever returning, and a loyal regular is worth several hundred dollars a year (about $600). The signal is invisible because it is an *absence*, spread across hundreds of people who each have their own rhythm.
66

77
An owner uploads a booking-history CSV from whatever they already use. Bloom works out each client's own visit rhythm, flags the ones drifting from it, and drafts a personal note to each. It is your appointment book, but it reads itself.
88

SUBMISSION.md

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ Bloom: the AI that catches the clients you're about to lose
4646
### Elevator pitch (max 200 characters)
4747

4848
```
49-
Nobody cancels, they just stop coming. Bloom reads your booking history, finds who is drifting from their own visit rhythm, and writes each one a personal note. One salon client is worth $1,200 a year.
49+
Nobody cancels, they just stop coming. Bloom reads your booking history, finds who is drifting from their own visit rhythm, and writes each one a personal note. A loyal regular is worth several hundred dollars a year, so saving even one covers the software.
5050
```
5151
(199 characters.)
5252

@@ -70,11 +70,12 @@ answer to a harder question, and it was a question I only found by reading the
7070
retention research for salons and barbershops.
7171

7272
Nobody cancels. They just quietly stop coming, and the owner finds out months
73-
later, if ever. About 40% of new clients are gone within a year. A first-timer
73+
later, if ever. A typical salon loses about 40% of its clients every year. A first-timer
7474
who does not rebook within 30 days has roughly a one-in-five chance of ever
7575
returning, which makes days 7 to 30 after a first visit the highest-value moment
76-
in the entire business and the one nobody is watching. And a regular is worth
77-
$1,200 to $3,000 a year, so this is not a rounding error, it is the largest
76+
in the entire business and the one nobody is watching. And a loyal regular is
77+
worth several hundred dollars a year (about $600 annualized), so this is not a
78+
rounding error, it is the largest
7879
single profit leak most shops have.
7980

8081
The reason nobody watches it is not laziness. It is that the answer is invisible:
@@ -215,9 +216,9 @@ something the market already gives away.
215216
The real problem was hiding in the retention research. In a salon or barbershop,
216217
nobody cancels. They just quietly stop coming, and the owner finds out months
217218
later, from a gap in the appointment book that nobody had time to notice. About
218-
40% of new clients are gone within a year. A first-timer who does not rebook
219-
within 30 days has roughly a one-in-five chance of ever returning. A regular is
220-
worth $1,200 to $3,000 a year. That is the largest single profit leak most shops
219+
40% of a salon's clients are gone within a year. A first-timer who does not
220+
rebook within 30 days has roughly a one-in-five chance of ever returning. A loyal
221+
regular is worth several hundred dollars a year. That is the largest single profit leak most shops
221222
have, and it stays invisible because it is an absence, spread across hundreds of
222223
people who each keep their own rhythm.
223224

@@ -256,7 +257,7 @@ has no employees but me. What it creates is not headcount, it is protected
256257
livelihood. A neighbourhood salon runs on thin margins and a handful of chairs,
257258
and every regular who drifts away unnoticed is revenue that would otherwise pay a
258259
stylist. When Bloom keeps 20 clients a year who would quietly have left, worth on
259-
the order of $25,000, that can be the difference between a shop cutting a chair and
260+
the order of $12,000, that can be the difference between a shop cutting a chair and
260261
keeping one, or hiring the next. The economic opportunity Bloom enables is the
261262
retained revenue base that local service jobs actually depend on, for people well
262263
beyond any founding team: the stylists, barbers, and front-desk staff whose hours
@@ -369,7 +370,7 @@ field instead.
369370
### Explain how your project uses AI to impact the world, specifically in the category you have chosen.
370371

371372
The biggest profit leak in a local service business is invisible. Roughly 40% of
372-
new clients are gone within a year, and a first-timer who does not rebook within
373+
a salon's clients are gone within a year, and a first-timer who does not rebook within
373374
30 days has about a one-in-five chance of ever coming back. Owners do not ignore
374375
this because they do not care. They ignore it because the signal is an ABSENCE
375376
spread across hundreds of people who each have a different rhythm, and because
@@ -379,8 +380,8 @@ Bloom removes both barriers. A deterministic engine finds the drift by measuring
379380
every client against their own median cadence, and Gemini writes each of them a
380381
note that reads like the owner remembered them, because it is given that person's
381382
real history. A shop with 400 clients might have 20 slipping in a given month,
382-
worth $25,000 a year between them. Saving three of them pays for the software for
383-
years.
383+
worth over $10,000 a year between them. Saving three of them pays for the software
384+
for years.
384385

385386
This is squarely Small Business Services: the AI does not advise the owner, it
386387
performs a service the business would otherwise pay a person for, and the outcome
@@ -398,8 +399,8 @@ to those clients requires a plan: Starter $49/month or Pro $99/month, billed
398399
through Stripe. Between the two tiers the only difference is who sends the weekly
399400
newsletter; both include the win-back, because that is the reason to pay at all.
400401

401-
The pricing argument writes itself: a lapsed regular is worth $1,200 to $3,000 a
402-
year, so recovering ONE client pays for a year of Starter several times over. I am
402+
The pricing argument writes itself: a lapsed regular is worth several hundred
403+
dollars a year, so recovering ONE client pays for a year of Starter. I am
403404
not asking an owner to believe a projection, I am showing them their own losses
404405
and charging less than one of them.
405406

prisma/schema.prisma

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ model Client {
7272
/// Median days between this client's visits. Null when they have only ever
7373
/// visited once, which is exactly the highest-risk case.
7474
cadenceDays Int?
75+
/// A drafted win-back the owner has not yet approved. Held here so the owner can
76+
/// read exactly what will be sent before it goes, which is the whole trust story
77+
/// of a product that emails your clients. Cleared on send or discard.
78+
winBackDraft String?
79+
winBackDraftedAt DateTime?
7580
/// Set when the agent has already reached out, so it never nags twice.
7681
winBackSentAt DateTime?
7782
/// Anti-spam law applies to win-back email too. Once set, the agent will never
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { NextRequest } from 'next/server'
2+
import Stripe from 'stripe'
3+
import { db } from '@/lib/db'
4+
5+
/**
6+
* Delete a business and everything attached to it.
7+
*
8+
* The privacy policy promises an owner can delete their data at any time, and
9+
* this is the code that keeps that promise. It is also the answer to the single
10+
* biggest objection this product has: an owner is uploading their entire client
11+
* list, and they need to know they can take it back.
12+
*
13+
* POST + token-gated, so a link prefetch can never wipe an account. Cancels any
14+
* live subscription immediately (the account is being destroyed, not paused),
15+
* then deletes the children before the parent, since the schema has no cascade.
16+
*/
17+
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
18+
const { id: businessId } = await params
19+
const token = request.nextUrl.searchParams.get('t') ?? ''
20+
21+
const business = await db.business.findUnique({ where: { id: businessId } })
22+
// 404 rather than 403 so the route never confirms a business exists.
23+
if (!business || !token || token !== business.dashboardToken) {
24+
return new Response('Not found', { status: 404 })
25+
}
26+
27+
// Stop billing first. If this throws, do NOT delete: leaving the account intact
28+
// is far better than destroying the record while a subscription keeps charging a
29+
// card with nothing left to point it at.
30+
if (business.stripeSubscriptionId && process.env.STRIPE_SECRET_KEY) {
31+
try {
32+
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
33+
await stripe.subscriptions.cancel(business.stripeSubscriptionId)
34+
} catch (err) {
35+
// "No such subscription" means it is already gone, which is fine; anything
36+
// else is a real failure and must stop the delete.
37+
const msg = String((err as Error)?.message ?? err)
38+
if (!/No such subscription|resource_missing/i.test(msg)) {
39+
console.error('Delete blocked, could not cancel subscription:', err)
40+
return page(
41+
'Could not delete yet',
42+
'I could not cancel your subscription, so I have not deleted anything, to be safe. Please try again in a minute, or contact support.'
43+
)
44+
}
45+
}
46+
}
47+
48+
// Children before parent, one transaction, so a half-delete cannot strand rows
49+
// that outlive the business they belonged to.
50+
await db.$transaction([
51+
db.client.deleteMany({ where: { businessId } }),
52+
db.subscriber.deleteMany({ where: { businessId } }),
53+
db.weeklyContent.deleteMany({ where: { businessId } }),
54+
db.agentLog.deleteMany({ where: { businessId } }),
55+
db.business.delete({ where: { id: businessId } }),
56+
])
57+
58+
return page(
59+
'Your account and data are deleted.',
60+
'Everything you uploaded is gone: your client list, your history, and your account. Nothing more will be sent to anyone. Thank you for trying Bloom.'
61+
)
62+
}
63+
64+
/** A plain confirmation page. There is no dashboard to return to anymore. */
65+
function page(title: string, body: string): Response {
66+
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8" />
67+
<meta name="viewport" content="width=device-width, initial-scale=1" />
68+
<title>${title}</title></head>
69+
<body style="margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f4f1e4;font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;color:#16150f">
70+
<div style="max-width:440px;padding:32px;text-align:center">
71+
<h1 style="font-size:1.4rem;margin:0 0 12px">${title}</h1>
72+
<p style="color:#56524a;line-height:1.6;margin:0 0 24px">${body}</p>
73+
<a href="/" style="display:inline-block;background:#16150f;color:#f4f1e4;text-decoration:none;padding:12px 22px;border-radius:2px;font-weight:600">Back to home</a>
74+
</div>
75+
</body></html>`
76+
return new Response(html, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } })
77+
}

src/app/api/businesses/route.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,19 @@ import { allowRequest, LIMITS } from '@/lib/ratelimit'
66
const schema = z.object({
77
name: z.string().min(1).max(100),
88
type: z.string().min(1),
9-
city: z.string().min(1).max(100),
9+
city: z.string().max(100).default(''),
1010
province: z.string().max(50).default(''),
1111
country: z.string().max(3).default('CA'),
12-
description: z.string().min(10).max(1000),
12+
// Optional now. The flagship is the retention radar, which needs none of this;
13+
// a description is only used for weekly content, so collecting it up front gated
14+
// a spreadsheet analysis behind a marketing question. Prompted for later, in the
15+
// dashboard, only when the owner turns content on.
16+
description: z.string().max(1000).default(''),
1317
brandVoice: z.enum(['friendly', 'professional', 'casual', 'bold', 'elegant']).default('friendly'),
1418
contentLanguage: z.enum(['en', 'fr', 'es', 'pt', 'it', 'de']).default('en'),
15-
// Optional at signup so the free preview stays frictionless. It is required
16-
// before Pro can email (enforced in the send path), and editable in the
17-
// dashboard, so a new owner is never blocked from seeing their first content.
19+
// Optional at signup so the radar stays frictionless. Required before any email
20+
// can send (enforced in the send path), and editable in the dashboard, so a new
21+
// owner is never blocked from seeing who is slipping.
1822
mailingAddress: z.string().max(200).optional(),
1923
promotions: z.string().max(500).optional(),
2024
ownerName: z.string().min(1).max(100),

src/app/api/clients/import/route.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { db } from '@/lib/db'
33
import { parseBookingCsv } from '@/lib/import-csv'
44
import { assessAll, summarize } from '@/lib/retention'
55
import { publicBaseUrl } from '@/lib/config'
6+
import { sampleCsv } from '@/app/api/sample-csv/route'
67

78
export const maxDuration = 60
89

@@ -20,6 +21,10 @@ export async function POST(request: NextRequest) {
2021
const token = form.get('t')?.toString() ?? ''
2122
const businessId = form.get('businessId')?.toString() ?? ''
2223
const file = form.get('file')
24+
// One-click "try it with sample data": seed the built-in sample book without
25+
// making a new owner download a CSV and upload it back. The follow-up sample
26+
// (a client rebooked) uses sample=2, so a demo can show a save land.
27+
const sample = form.get('sample')?.toString() ?? ''
2328

2429
const business = await db.business.findUnique({ where: { id: businessId } })
2530
// 404 rather than 403 so the route never confirms a business exists.
@@ -31,14 +36,19 @@ export async function POST(request: NextRequest) {
3136
const back = (params: string) =>
3237
`${origin}/dashboard/${businessId}/clients?t=${encodeURIComponent(token)}&${params}`
3338

34-
if (!(file instanceof File) || file.size === 0) {
35-
return Response.redirect(back('import_error=nofile'), 303)
36-
}
37-
if (file.size > MAX_BYTES) {
38-
return Response.redirect(back('import_error=toobig'), 303)
39+
let text: string
40+
if (sample === '1' || sample === '2') {
41+
text = sampleCsv(sample === '2')
42+
} else {
43+
if (!(file instanceof File) || file.size === 0) {
44+
return Response.redirect(back('import_error=nofile'), 303)
45+
}
46+
if (file.size > MAX_BYTES) {
47+
return Response.redirect(back('import_error=toobig'), 303)
48+
}
49+
text = await file.text()
3950
}
4051

41-
const text = await file.text()
4252
const result = parseBookingCsv(text)
4353

4454
if (result.error) {
@@ -48,7 +58,14 @@ export async function POST(request: NextRequest) {
4858
return Response.redirect(back('import_error=norows'), 303)
4959
}
5060

51-
const clients = result.clients.slice(0, MAX_CLIENTS)
61+
// If a book is over the cap, keep the most recently active clients rather than
62+
// an arbitrary slice: a blind slice(0, N) kept whoever happened to appear first
63+
// in the file and could drop the newest first-timers, who are the exact cohort
64+
// on the 30-day cliff that this product exists to catch.
65+
const clients =
66+
result.clients.length > MAX_CLIENTS
67+
? [...result.clients].sort((a, b) => b.lastVisitAt.getTime() - a.lastVisitAt.getTime()).slice(0, MAX_CLIENTS)
68+
: result.clients
5269

5370
// Everyone the agent wrote to who has now booked again. Keyed by email, which is
5471
// the actual unique constraint: two clients can share a display name, and

0 commit comments

Comments
 (0)