A full-stack web platform where users browse and book companions for activities, from hiking and museum visits to cooking classes and city tours.
First coding project · built solo
Standin is a marketplace platform that solves a simple but underserved problem: finding someone to do things with. Whether it's a museum visit, a hike, a cooking class, or a weekend city tour, the platform lets users browse verified companions, filter by activity type and availability, and book directly through a streamlined checkout flow.
The project started from the observation that existing platforms for companion services were either overly complex, poorly designed, or lacked trust signals. Users needed a clean, transparent experience: see who's available, read reviews, pick a time, and book. No hidden fees, no endless forms.
Built as a solo full-stack project over twelve weeks, Standin ships a responsive multi-page frontend backed by Supabase (Postgres with row-level security, authentication and file storage), a set of TypeScript edge functions for the server-side logic, Stripe Checkout for payments, and an availability system that prevents double bookings. The codebase is organised around clear domain boundaries so it can grow from MVP toward production without a rewrite.
The platform handles the full lifecycle: companion onboarding, activity listings, search and discovery with city, date and interest filters, slot-based booking, secure Stripe payments, transactional email, cancellation with refunds, and a review system that builds trust over time.
Building a two-sided marketplace means solving for both sides simultaneously. Companions need easy onboarding and schedule management. Users need trust signals, transparent pricing, and instant confirmation. The technical challenge: making all of that feel simple.
Deliver a working platform where companions can onboard and manage their availability with ease, and users can find someone, see transparent pricing and book with instant confirmation, making a complex two-sided system feel effortless on both sides.
As the sole developer, I owned every layer from database schema to deployed frontend. The work spanned architecture decisions, implementation, testing, and deployment. I worked in an AI-assisted workflow, using Claude to accelerate implementation while owning the architecture and every key decision myself.
I had the bulk of the app standing within a few days. The real work came after that: testing every function and debugging the errors that surfaced, while new features kept being added along the way. That cycle, not the initial build, is where most of the time went.
My way of working was deliberate and incremental, a bit like building with Lego. For each page and each function I told Claude exactly what I had in mind, looked closely at what it produced, and used that as a scaffold to build on. I checked what I liked, what I didn't, and what was still missing, then kept building on top of it, step by step, until the page or function did what it should. I repeated that same loop for every part of the product. Throughout, I focused about 90% on function and less on the visual design: I only changed the design when it really didn't match what I had in mind, or when it got in the way of how the function was meant to be used.
I also set up a large part of the infrastructure myself: creating the accounts for Supabase, Vercel, Stripe and the other services, and wiring them all together into one working system.
The platform runs on static HTML pages with vanilla JavaScript, deployed on Vercel with Supabase as its backend. I designed and shipped it as a solo project with AI-assisted development, making the product, data-model and architecture decisions, integrating the services, and handling testing and deployment. The stack is deliberately lean: no build step, no bundler, no framework, no package.json, just static HTML and CSS with the Cormorant Garamond and DM Sans typefaces from Google Fonts. The pages communicate with Supabase directly from the browser, which keeps the build simple to ship and easy to serve from any static host or CDN.
Supabase provides the Postgres database, the authentication and the file storage, with the server-side logic written as serverless edge functions in TypeScript for the Deno runtime, covering checkout, the Stripe webhook, identity verification, booking cancellation, account deletion and email. Stripe handles payments, refunds and Identity for KYC, the step where trust is built, while Resend sends the transactional email. The static frontend is deployed on Vercel, the code is versioned with Git, and the Supabase and Stripe MCP servers are wired in for AI-assisted development.
The application is split into four bounded domains. Each domain owns its data, edge functions, and business logic, keeping the codebase navigable as features grow.
Every feature followed the same loop: data and access rules first, server logic second, UI last. Working in that order caught data-model and permission mistakes before any interface code existed. The booking flow below is the clearest example.
create-checkout verifies the login, checks the slot is still free, rejects overlapping bookings, then opens a Stripe Checkout session.The core journey had to feel effortless on both sides: a customer finding and booking a companion, and a companion staying in control of who, when and for what.
The schema runs on Supabase Postgres and centres on a single profiles table that holds both customers and companions, distinguished by a role column and mapped one-to-one onto Supabase Auth. Around it sit roughly a dozen tables for bookings, availability, reviews, messaging, community and operations, all linked back to profiles and guarded by row-level security.
role
The rest of the schema covers messaging (messages, notifications), engagement (favorites, community_posts, community_likes) and operations (payout_requests, reports, feedback, profile_change_requests), all linked back to profiles. Images and documents live in three Supabase Storage buckets (avatars, community, verification-docs); the database keeps only their URLs.
profiles (split by role, 1:1 with Supabase Auth), for simpler auth and fewer duplicates.deleted_at instead of being hard-deleted.approved, profile_change_requests and reports gate content before it goes public.stripe_session_id, files live in Storage, and row-level security guards every row.All backend logic runs in Supabase Edge Functions: serverless TypeScript on the Deno runtime, each one its own HTTP endpoint that only spins up when called. Anything that must not happen in the browser lives here: paying with the secret Stripe key, deleting accounts, writing records a user isn't allowed to write directly. The frontend just calls these endpoints; the sensitive logic stays protected.
Depending on the task, a function uses one of two database clients, and usually both, in order.
metadata and return its URL.verified flag.CASCADE clears every related row; finally remove the Auth user.One central sender that builds a ready HTML email per type: booking confirmations and cancellations for both sides, plus admin alerts for new reports and feedback.
No Figma prototype: the application was built directly as working software from day one. Three real pieces of the marketplace's core: opening a paid booking, confirming it from Stripe's webhook, and cancelling it safely.
The customer first sees all of a companion's available dates, then picks one of the activities the companion offers, gives a public meeting point and can add an optional message (which becomes the first message in the chat). Before any money moves, the create-checkout edge function re-checks server-side that the slot is still free (never trusting the browser) and opens a Stripe Checkout session that carries the whole booking in its metadata.
1
2const { data: { user } } = await supabase.auth.getUser() if (!user) return json({ error: "Nicht eingeloggt" }, 401) // re-check the slot server-side, never trust the client const { data: slot } = await admin .from("availability").select("is_booked") .eq("id", availability_id).single() if (slot?.is_booked) return json({ error: "Termin nicht mehr verfügbar." }, 409) // open Stripe Checkout via the REST API, no SDK const res = await fetch("https://api.stripe.com/v1/checkout/sessions", { method: "POST", headers: { Authorization: `Bearer ${STRIPE_SECRET_KEY}` }, body: params, // price + booking metadata }) return json({ url: (await res.json()).url })
The booking is never written when the user clicks “pay”, only when Stripe's signed webhook reports the payment as complete. The Stripe session id makes it idempotent, so a replayed event can't create a second booking.
// signature already verified above if (event.type !== "checkout.session.completed") return json({ received: true }) const session = event.data.object if (session.payment_status !== "paid") return json({ received: true }) // idempotent: one Stripe session → at most one booking const { data: existing } = await admin .from("bookings").select("id") .eq("stripe_session_id", session.id).maybeSingle() if (existing) return json({ received: true }) await admin.from("bookings").insert({ ...session.metadata, stripe_session_id: session.id, status: "confirmed", })
A customer can only ever load their own booking: row-level security enforces that at the database. Cancellation is allowed up to 24 hours before the slot; inside that window the request is refused with a clear message. When a booking is cancelled the standin is notified by email.
// RLS: the customer can only read their own booking const { data: booking } = await userClient .from("bookings").select("status, date, time, stripe_session_id") .eq("id", booking_id).single() // only cancellable up to 24h before the slot const start = new Date(`${booking.date}T${booking.time}`) const hoursUntil = (start.getTime() - Date.now()) / 3_600_000 if (hoursUntil < 24) return json({ error: "Stornierung nur bis 24 Stunden vorher möglich." }, 400) // refund via Stripe, then free the slot again await refundPayment(booking.stripe_session_id)
A messaging function so the customer and the companion can write to each other before and during the meeting. That way they never have to swap a phone number or email: privacy stays protected and everything happens inside the app. They can clear up any open questions ("do I need to bring anything?") or reach each other if they can't find one another, and it's a good way to get in touch before the meeting. When booking, the customer can add a message, and it shows up as the first message in the chat, and the companion is notified right away.
// the remark added at checkout shows up as the first message if (booking?.message) renderBubble(booking.message, "Bemerkung zur Buchung") // the thread is scoped to one booking, only its two // participants (sender + receiver) can read it const { data: msgs } = await supabase .from("messages").select("*") .eq("booking_id", chatBookingId) .order("created_at", { ascending: true }) // sending a new message await supabase.from("messages").insert({ booking_id: chatBookingId, sender_id: currentUser.id, receiver_id: chatReceiverId, content, })
In the admin dashboard I built a live preview of every page, onboarding included. This helped most while coding: when I had to make small changes I didn't have to register as a new user over and over just to see the onboarding, or log in as a user to reach a page. I could see all the current pages at a single glance. It's one of the ideas I'm proudest of in this build: a small piece of tooling I came up with myself that paid off every single day.
const pages = [ { title: "Startseite", url: "index.html" }, { title: "Kunden-Dashboard", url: "standins.html" }, { title: "Standin-Dashboard", url: "dashboard.html" }, { title: "Profil", url: "profile.html" }, { title: "Onboarding Kunde", url: "onboarding-customer.html" }, { title: "Onboarding Standin", url: "onboarding-standin.html" }, ] // ?preview=1 → the page skips its usual role/auth redirects const withPreview = u => u + (u.includes("?") ? "&" : "?") + "preview=1" // desktop-sized iframe, scaled down so a whole page fits a card const SCALE = 0.32, FRAME_W = 1280, FRAME_H = 1800
To keep both customers and companions safe, I made it so the core profile data (name, age and gender) can't be changed freely, only on request. So I built a feature where you can request a change to, for example, your name, together with a reason. We review the request and then carry out the change.
// name, age and gender aren't editable inline, // the user picks a field and gives a reason instead if (!newValue) return showError("Bitte neuen Wert angeben.") if (!reason) return showError("Bitte Begründung angeben.") // age is bounded: standins 20+, customers 18+ if (field === "age") { const min = profile.role === "standin" ? 20 : 18 if (+newValue < min || +newValue > 99) return showError(…) } // nothing changes yet, it's filed for review await supabase.from("profile_change_requests").insert({ user_id: currentUser.id, field_name: field, // first_name · last_name · age · gender new_value: newValue, reason, })
Right in the onboarding I make it explicit which details are shown publicly and which are only used internally: every field is labelled. The first name is public, only the first letter of the surname is shown, and gender and date of birth stay private. This gives the user a feeling of security: they know exactly what happens with their data, which lowers the hurdle to signing up.
<!-- every field says how visible it is --> <label>Vorname (öffentlich)</label> <label>Nachname (nur Anfangsbuchstabe öffentlich)</label> <label>Geschlecht (nicht öffentlich)</label> <label>Geburtsdatum (nicht öffentlich)</label> // gender + birthdate are stored, but never rendered publicly await _supabase.from("profiles").update({ first_name: firstName, last_name: lastName, gender, birthdate, age, // private, internal use only }).eq("id", currentUser.id)
Everyone (companions and customers alike) has to give their age at sign-up, but it's never shown publicly. I kept it hidden on purpose: otherwise the platform slips into dating-app territory, and when you meet people in real life who become friends you don't ask their age first either. To keep companions safe, though, they can choose which gender they want to accompany and in which age range. They're then only shown to the customers who match, so they stay in control and feel safe.
// age is collected at sign-up, but never shown publicly // a standin only appears to the customers they want to meet filtered = filtered.filter(s => { const genderPref = s.client_gender_pref ?? "beide" if (genderPref !== "beide" && custGender && genderPref !== custGender) return false if (!inAgeRange(custAge, s.client_age_pref)) return false return true }) function inAgeRange(age, range) { if (!range || range === "egal") return true const [min, max] = range.split("-").map(Number) return age >= min && age <= max }
In the standin dashboard I built a calendar where companions enter the slots they're available for in advance, so customers can book them. A slot has to start at least 4 hours from now to be bookable, which leaves both sides enough lead time to prepare. As long as a slot hasn't been booked yet, the companion can delete it again, which gives them a clean overview of everything they've entered. Once a slot is booked it's binding.
// companion adds a free slot, only fixed 1–3h durations const allowed = [60, 90, 120, 150, 180] // minutes if (!allowed.includes(duration)) return showAvailMsg("Nur 1–3h.") await supabase.from("availability").insert({ standin_id: currentUser.id, date, time_start, time_end, }) // the list shows each slot as Frei / Gebucht, a delete // button appears only while it's still free and not past if (!slot.is_booked && !isPast) renderDeleteButton(slot.id) async function deleteSlot(id) { await supabase.from("availability").delete().eq("id", id) }
On a platform where strangers meet, this is really important. To protect the community (customers and companions alike), I built a report function: any user can report someone they've met, with a reason (no-show, inappropriate behaviour, harassment, or something else) and an optional description. The report lands with the admin, who can look into it and, if needed, ban the user, so anyone who breaks the guidelines is removed from the platform.
// reasons: no_show · inappropriate · harassment · other const reason = reportReason.value const message = reportMessage.value.trim() // optional await supabase.from("reports").insert({ reporter_id: currentUser.id, reported_id: reportedId, booking_id: bookingId || null, reason, message: message || null, }) // the admin is notified and can review, and ban if needed await supabase.functions.invoke("send-email", { body: { type: "new_report", report: { reason } }, })
A community feature where a standin or a customer can post about a meeting they had together, with a selfie or a photo of the location. Either person can write a post about any of their past experiences. But it only goes public once the other person involved agrees, so nobody is shown without their consent.
// either participant can post about a past booking await supabase.from("community_posts").insert({ user_id: currentUser.id, partner_id: booking.standin_id, booking_id: booking.id, title, description, image_url, approved: false, // private until the partner agrees }) // only the other person can approve, then it goes public await supabase.from("community_posts") .update({ approved: true }) .eq("id", id).eq("partner_id", currentUser.id) // the public feed shows approved posts only supabase.from("community_posts").select("*").eq("approved", true)
When you open a standin's profile, you see the whole profile: profile picture, price, location, a short bio, the activities they offer and whether they're verified, together with all of their reviews and the average rating.
// the full profile: bio, activities, verified badge, price … renderHeader(s) // avatar · name · ${s.city} · Seit ${sinceYear} // … plus every review the standin has received const { data: reviews } = await supabase .from("reviews") .select("rating, comment, activity, created_at, tags, reviewer:reviewer_id(first_name)") .eq("standin_id", standinId) // average rating shown next to the stars const avg = (reviews.reduce((s, r) => s + r.rating, 0) / reviews.length).toFixed(1)
A feature where a standin can see how much they've earned across all their bookings and how much is deducted. There are three figures: the amount already paid out, the amount requested, and the amount available for payout. 20 days after a booking and a positive review (more than 4 stars), the standin can request a payout at the push of a button (for the available amount), and after we check it the money is transferred to the IBAN they provided. The advantage: the admin only has to pay out once a request is made, not continuously.
const PAYOUT_DAYS = 20, PLATFORM_FEE = 0.20 // a booking is payable 20 days after the date, once it has ≥4★ const eligible = bookings.filter(b => (reviewMap[b.id] ?? 0) >= 4 && new Date(b.date) <= cutoff) // three totals (net = gross − 20% platform fee) const paid = sum("paid") // already paid out const requested = sum("requested") // awaiting transfer const available = sum("eligible") // ready to request // the standin requests it, admin only pays out on request await supabase.from("payout_requests").insert({ standin_id: currentUser.id, amount_chf: available, iban: profile.iban, })
Alongside the report function, the review feature is an important part of the platform's safety. After a shared experience the customer can rate the standin: a star rating, plus keywords like "punctual", and an optional free text. This helps other customers form a picture of a standin and rewards the good ones. On top of that, standins get a ranking based on their reviews, and every month a "Standin of the Month" is crowned through them.
if (!selectedRating) return showError("Bitte wähle eine Bewertung.") // quick keyword chips: "Pünktlich", "Freundlich", … const tags = [...document.querySelectorAll(".review-tag.selected")] .map(t => t.textContent) await supabase.from("reviews").insert({ booking_id: reviewBookingId, reviewer_id: currentUser.id, standin_id: b.standin_id, rating: selectedRating, // 1–5 stars comment: comment || null, // optional free text tags, }) // reviews feed the ranking + monthly "Standin of the Month"
A feedback and support feature where users can ask questions when they run into trouble, report a problem or a bug, or simply say thanks and tell us what they like. Each message is filed under a category (question, bug, suggestion, praise, …), goes to the admin, and the admin can reply right inside the app.
// categories: question · bug · suggestion · praise · other const category = fbCategory.value const message = fbMessage.value.trim() if (!message) return showError("Bitte Beschreibung eingeben.") await supabase.from("feedback").insert({ user_id: currentUser.id, category, message, page: "standins", user_agent: navigator.userAgent, }) // admin is notified, and can reply right inside the app await supabase.functions.invoke("send-email", { body: { type: "new_feedback", feedback: { category, message } }, })
A profile-preview feature where both customers and standins can see, right from their own profile, exactly how they're shown to the other side: a customer sees how standins see them, a standin sees how their profile appears to customers. It's the very same card that's used in the listing.
const isStandin = profile.role === "standin" // the header adapts to who is looking previewHeader.textContent = isStandin ? "So wird dein Standin-Profil angezeigt" : "So sehen dich Standins" // render the exact card customers see in the listing: // avatar, price, tags, "Neu" badge, rating (standins only) previewCard.innerHTML = renderStandinCard(profile, { reviewCount })
I wrote community guidelines for both standins and customers: clear rules on how to behave and on where and how to meet. They draw a sharp line: Standin is explicitly not a dating platform and has nothing to do with escort services. Meetings happen only in public places, and the basics (no harassment, no racism, no discrimination) are non-negotiable. Everyone has to agree to them, and if someone breaks the rules any user can report that person, who can then be removed from the platform.
<ul> <li><strong>Nur öffentliche Orte</strong>: Treffen finden ausschliesslich an öffentlichen Orten statt.</li> <li><strong>Korrekte Angaben</strong>: Name, Alter und alle Profilangaben müssen wahrheitsgemäss sein.</li> <li><strong>Kein unangemessenes Verhalten</strong>: Belästigung, Diskriminierung oder Gewalt führen zum sofortigen Ausschluss.</li> <li><strong>Plattform-Zahlung</strong>: Bezahlung nur über Standin, keine Barzahlung.</li> </ul> // Verstoss? → jeder kann melden, Konto kann gesperrt werden <div class="note">Bei Verstössen kann dein Konto vorübergehend oder dauerhaft gesperrt werden.</div>
A marketplace doesn't just need customers and companions. It needs someone keeping it safe and running. A private admin surface, gated server-side by an is_admin check and row-level security, is where that happens. A bar across the top gives the state of the platform at a glance: how many customers and companions have signed up, how many bookings have been made, the gross revenue, and the platform's own earnings from its fees.
From there the work is split into clear areas, each its own tab: Users, Bookings, Reviews, Reports, Verifications, Change requests, Payouts, Feedback, Community, Leaderboard, and a live preview of every page.
Each area carries the actions that belong to it: sending a password reset, hiding, suspending or deleting an account, cancelling a booking, removing a review, reading and approving reports, verifying a user, approving or rejecting personal-data change requests, approving a companion's payout, answering feedback, questions and problems, taking down community posts, and seeing how companions rank on the leaderboard. The live preview lets the admin view every page of the app at any time, all from one place.
Privacy shaped what the admin gets to see at all. I was deliberate about keeping customer data to a minimum here: anything not needed to carry out a task is either left off the screen entirely or shown redacted, so only the bare essentials are visible. Moderating a report or releasing a payout never means putting someone's full personal record in front of an admin.
New customers sign up through a guided four-step flow: a profile picture, the city they want to meet in, a few personal details, and their interests. Step through it yourself below.
Core views from the shipped application, across both sides of the marketplace: from discovery, booking and payments to profiles, availability and the leaderboard.
I focused above all on the functional side of the design: a clean, minimalist interface with clear, strong, self-explanatory copy and only the functions that are genuinely needed. The platform does a lot (onboarding, discovery, booking, payments, chat, reviews, reporting, payouts), yet it still feels simple, uncluttered and very intuitive to use.
The admin area lets every feature be managed and monitored from one clear overview. And because the product processes personal information about both customers and standins, I was deliberate about privacy, collecting only the data that's strictly necessary and protecting it where it matters. Visually I stayed true to the original Standin design and built on it rather than reinventing it: the same forest green, gold and warm beige throughout.
A working product still needs to be usable. I ran moderated, in-person usability sessions with two users, one of whom I tested with twice. Each of them first had to register and complete the onboarding to set up their profile, then browse the standins (exactly the start any real user has) before searching for a companion, picking a slot and completing a payment.
I watched where they hesitated, dropped off or misread something. Booking and payment worked flawlessly for every tester, and on the whole they found the product intuitive and easy to use. Everyone really liked the design, and they were taken with the concept itself: they said they'd definitely need a product like this, though they weren't sure how often they'd use it, since after a first meeting people might simply meet up again off the platform. Beyond the flow, the sessions surfaced three things they wished the product did differently, all rooted in the fact that this is about meeting real people in real life.
Two of those wishes I acted on; the third I deliberately held back. What I changed, and the reasoning behind each call, is in Iteration.
"I'd feel safer meeting a standin in real life if they were verified first. That's the thing that would make me trust it."Testing was deliberate and hands-on rather than a large automated suite. The whole customer journey (register, onboard, find a companion, book, pay, cancel, review) runs as a written end-to-end checklist against a fresh account, so onboarding and edge cases are never accidentally skipped.
The point isn't the happy path, it's the breaks. The checklist forces the failure modes on purpose: a rejected test card, a double booking on the same slot, an expired slot, a cancellation inside the 24-hour window, each with a defined, verified behaviour.
Payments run in Stripe's test mode with the standard test card, all the way through the signed webhook to a confirmed booking, the same path a real user triggers, minus the money. And before the booking flow was settled, I ran it past real people, not just myself.
"Test the failure modes, not just the happy path."Some of the most important work on Standin was never visible in the interface: finding and fixing errors whose real cause was hidden behind a misleading symptom. The following are the ones that stand out, found and fixed while building and running the product.
Users were unable to log in. The screen reported that the email or password was incorrect, but resetting the password did not help, because that was never the actual problem. The real cause was that the project's database, running on Supabase's free plan, paused itself automatically after seven days without activity. This had nothing to do with the amount of stored data, which is what I first suspected. While the database was paused, no one could sign in.
I first had to identify the real cause, since the failure looked like an incorrect password. Once it was clear that the paused database was responsible, I set up a scheduled task on GitHub that contacts the database automatically every two days. This keeps the inactivity timer from ever running out, so the database no longer pauses. It runs on GitHub's servers, costs nothing, and needs no manual restart. In addition, I replaced the misleading message: instead of stating that the email or password is wrong, it now indicates that the service cannot be reached at the moment, so users understand that the problem is not on their side and do not try to reset a password unnecessarily. Looking ahead, a paid plan would remove the seven-day limit entirely and handle heavier usage without throttling, and monitoring could alert the admin immediately so they can react without delay.
The booking flow contained two separate pricing errors, one after the other. In the first version a booking was deliberately simple: to keep the model lean, every activity was a fixed one-hour event at a fixed price, so there was nothing to calculate. Later, bookings could last 1.5 hours or more, and companions could set an individual price for each activity, which is where the errors came from. The first error was in the booking step: the price was not recalculated for the chosen duration. If a customer selected 1.5 hours, the booking still showed only the price for one hour. Once that was corrected, a second error remained in the payment step: Stripe was always sent the fixed standard price of 20 and did not use the individual price that a companion had set for the selected activity.
I fixed the two errors in sequence. First I corrected the calculation in the booking step so that the price is the hourly rate multiplied by the actual duration of the slot, which means 1.5 hours is now priced as 1.5 times the hourly rate. Then I corrected the amount sent to Stripe so that it uses the individual price of the booked activity, and only falls back to the standard price when no individual price is set. To make this reliable, the server now calculates the final amount itself from the stored booking data instead of relying on the value sent by the browser, so a manipulated request cannot lower the price that is actually charged, and the price the customer sees, agrees to and pays is always the same.
The list of available slots was filtered only by date, not by time. As a result, the current day counted as fully available, including the hours that had already passed. For example, if a companion was available from 14:00 to 16:00 and a customer opened the booking window at 15:30, that slot was still shown as free and could be selected, even though it had already started. It was therefore possible to book and pay for a time that was already in the past or currently running.
I changed the filter so that it compares both the date and the time against the current moment, instead of the date alone. After the slots are loaded, any slot that starts before the current time is removed, so slots that are already running or in the past no longer appear in the selection. While working on this I also found a related time bug in the new payments overview, where a timestamp was shown in the wrong time zone, which I corrected separately. As a next step, a minimum lead time of four hours is planned, so that a companion always has enough time to prepare and travel to the meeting point before a booking starts.
When a user clicked the reset link in the email, the field for entering a new password never appeared. Instead, the user was taken straight to the dashboard, without ever getting the chance to set a new password. The reset link signs the user in through a recovery token, and the page's redirect logic, which normally sends a logged-in user to their own area, ran as soon as the page loaded and therefore took effect before the reset screen could be shown. The underlying cause was a timing problem between the login event, which arrives asynchronously, and the redirect code that runs immediately when the page loads, so depending on the timing the user was sent away before the reset was even recognised.
I made sure the reset process always takes priority over the redirects, and I built the detection so that it cannot be missed. The reset state is now recognised from three independent sources: the address of the link, a query parameter, and a stored marker that survives page reloads and navigation. In addition, a listener reacts to the password recovery event even when it arrives late. While the reset is active, every redirect is blocked and a dialog asks the user for the new password, so the process now works reliably regardless of timing or which page the user lands on.
The message button passed the complete booking data, including names and activity descriptions, directly into the button's click handler in the HTML. The problem was that this data sat inside an attribute that is itself wrapped in quotation marks, so the first quotation mark inside a name or description ended the attribute too early and broke the rest of the markup. Special characters such as an apostrophe in a name had the same effect. The result was an invalid handler, so the chat either did not open or produced an error, depending on which booking was clicked. It looked like a backend problem, but the cause was entirely in the frontend.
Instead of placing the booking data inside the HTML, I now store each booking in a lookup table when the list is rendered and pass only its ID to the button. An ID contains no quotation marks or special characters, so it cannot break the markup, and the full booking is retrieved from the table when the button is clicked. In addition, any message text is treated as plain text when it is displayed, instead of being inserted directly into the page, which prevents the same kind of problem from appearing again inside the chat itself.
Deleting a user in the admin area did not behave consistently. Sometimes the request failed silently with an authorisation error and nothing happened. Other times the user disappeared from the list but remained in the database together with all related data, such as bookings, messages and reviews. Two real errors, a missing authorisation token and missing cleanup of the user's profile, were combined with an intentional design decision to delete users in a reversible way first, which made it hard to tell what was a bug and what was intended.
The delete function now reads the authorisation token from the request and passes it correctly, which resolved the failed requests. For a final deletion it removes the user's files, deletes the profile so that all related data is removed automatically, and then removes the account, with a clear error message at each step. Deletion remains a two-stage process: a reversible removal first, followed by a permanent deletion that requires an explicit confirmation. Administrators cannot delete other administrators.
A companion can define which customers they want to accompany, for example by gender or by age group. This setting was saved correctly, but it was never applied when the list of companions was shown to customers. The customer view only applied the filters that the customer themselves set, such as city, activity or price, and never checked the companion's preference in the other direction. As a result, a customer could see companions who had explicitly chosen not to accompany customers of that gender or age group. The data existed in the database, the filter for the customer view simply did not read it.
When the list of companions is built, it now also checks each companion's own preference against the logged-in customer's gender and age, and hides companions that do not match. The age check is intentionally tolerant, so that a missing preference or a missing age never excludes anyone by mistake. The same correction had to be applied on both pages where customers browse companions, because the filtering existed in both places and fixing only one would have left the problem active on the other. In the admin preview the filter is skipped on purpose, so that an administrator can still see the complete, unfiltered list of companions.
Two separate problems had the same underlying cause: the database security rules block access without reporting an error. When a rule does not allow something, a read simply returns no data and an update simply changes no rows, but no error is raised. Because of this, two issues were hard to spot. In the companion's dashboard the customer's name was missing, because companions had no permission to read the profile of their customer. And in the admin area, actions such as suspending, deleting or changing the status of a user showed a success message even though nothing had actually changed in the database.
I solved it on both sides. For reading, I added a specific rule that allows the two users who are connected by a booking to read each other's profile, and nothing more, after which the customer's name appeared again. For writing, each admin action now checks how many rows were actually changed and reports a clear error when none were, instead of showing a success message that is not true. This check was added to every admin action that changes data, so a blocked change is now always reported instead of silently appearing to succeed.
Like any real build, parts of Standin changed once they met real users rather than a plan on paper. The most useful changes came from hitting an actual limitation (in the data model, the booking logic, or the flow), not from anticipating a theoretical one.
The clearest example was verification. I had actually built a verification feature early on, then took it back out before the usability test: this is an MVP, everyone already has to confirm their email at registration, and I didn't want to raise the data hurdle any higher than it needed to be. The sessions reversed that call: testers consistently cared more about knowing the other person was verified than about giving up a little of their own privacy. So verification went back in, this time open to every user, with the admin reviewing each request and a “✓ Verifiziert” badge on the profile.
Pricing changed the same way. At the start I deliberately kept it as simple as possible for the MVP (a single base price per companion) just to see whether the principle works at all and whether there's real demand, before adding any complexity. Once it was clearly resonating, the flat rate started to feel wrong: one price doesn't fit when a person offers very different activities. So pricing moved to per-activity, each one priced on its own.
The free option is the one place I deliberately didn't follow the testers. Their point was fair: money doesn't fit every activity, sometimes it really is just about spending time together. But the core idea of Standin is that you pay for a companion: there are already platforms for finding people to do things with for free, and Standin lives on its fees. Paying is also what lets the platform guarantee you can always find someone, without searching forever for the perfect match or building a friendship first. It's about getting an activity done, for people short on time or contacts, and it gives younger people a real way to earn money while meeting others. Two users weren't enough to outweigh that, so I held the free option back: first prove that the paid principle resonates, then add it later as an optional extra if it's still wanted.
The changes test users asked for most, all built straight into the product after usability testing.
The MVP is live. Now the focus moves from building to validating: testing it with real people, sharpening the experience, and making sure it holds up.
In twelve weeks Standin went from an empty repository to a working, two-sided marketplace in production: users can discover companions, book and pay, cancel and review, all on a codebase built to be handed to a team rather than thrown away after the MVP.
Across this build, these were the parts of engineering I felt most confident in and enjoyed the most, from shaping the data model and the architecture to the money-and-trust paths where mistakes are expensive.