Match with a local buddy in Shenzhen who shares your interests. Casual, affordable, personal.
SocialTypeScript · PLpgSQLGitHub @tinaba96OnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Loading the full README. The saved excerpt is shown below.
By Tensai Tech Inc. — peer-to-peer travel buddy matching, starting in
Shenzhen. International travelers find a local who shares their interests;
the local shows them around.
Repo slug remains shenzhen-buddies (lowercase, hyphenated) for npm/URL
conventions, but anywhere user-facing the product is "Shenzhen Buddies".
Stack: Next.js 16 (App Router), TypeScript, Tailwind v4, Supabase
(Auth + Postgres + Storage + Realtime), Stripe (subscriptions), deployed on
Vercel.Read the full README ↗By Tensai Tech Inc. — peer-to-peer travel buddy matching, starting in Shenzhen. International travelers find a local who shares their interests; the local shows them around.
Repo slug remains
shenzhen-buddies(lowercase, hyphenated) for npm/URL conventions, but anywhere user-facing the product is "Shenzhen Buddies".
Stack: Next.js 16 (App Router), TypeScript, Tailwind v4, Supabase (Auth + Postgres + Storage + Realtime), Stripe (subscriptions), deployed on Vercel.
/u/[id]/pricing)Supabase project — create one (or use an existing one) and copy from Settings → API:
NEXT_PUBLIC_SUPABASE_URLanon public key → NEXT_PUBLIC_SUPABASE_ANON_KEYservice_role key → SUPABASE_SERVICE_ROLE_KEY (server-only secret;
used by the Stripe webhook to write rows that bypass RLS)Environment variables
cp .env.local.example .env.local
# paste values from Supabase + Stripe into .env.local
Database schema — open the Supabase SQL editor and run each migration in order:
supabase/migrations/0001_init.sql — profiles + RLSsupabase/migrations/0002_avatars.sql — avatar_path column + public
avatars storage bucketsupabase/migrations/0003_chat.sql — conversations, messages,
RLS, get_or_create_conversation RPC, Realtime publicationsupabase/migrations/0004_reviews.sql — reviews + RLS (insert
requires prior message history)supabase/migrations/0005_notifications.sql — per-user last_read_at
on conversations + mark_conversation_read RPCsupabase/migrations/0006_subscriptions.sql — subscriptions mirror
of Stripe state (only needed if you wire up Stripe)supabase/migrations/0007_match_role_constraint.sql — guide↔tourist
constraint on conversationssupabase/migrations/0008_bookings.sql — availability_windows +
bookings for the single-guide beta (originally one booking per day;
0018_booking_gap.sql relaxes that to any number of bookings at least
two hours apart, enforced by an exclusion constraint)supabase/migrations/0009_booking_payments.sql — adds Stripe payment
columns + pending_payment hold state to bookingssupabase/migrations/0019_one_booking_at_a_time.sql (0015 adds PayPal
columns — payment_provider, paypal_order_id, paypal_capture_id;
0017 widens the tour length for the free pilot; 0018 replaces the
one-booking-per-day lock with the two-hour gap rule; 0019 adds the
one-live-booking-per-tourist trigger)Auth settings (Supabase dashboard → Authentication → URL Configuration)
http://localhost:3000 (plus your Vercel URL once deployed)http://localhost:3000/auth/confirm (plus the
production equivalent).Stripe — powers premium subscriptions (/pricing) and one-time
booking payments (/guide). Leave STRIPE_SECRET_KEY blank during pilot to
disable both: bookings then skip checkout and go straight to admin review.
price_...).sk_test_...).<your-site-url>/api/stripe/webhook
listening for:
checkout.session.completed — confirms a paid booking / subscriptioncheckout.session.expired — frees the held day if a booking checkout
is abandonedcustomer.subscription.created/updated/deleted — subscription sync
Copy its signing secret (whsec_...)..env.local: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET,
NEXT_PUBLIC_STRIPE_PRICE_ID (price ID only needed for subscriptions).FREE_TOURS in src/lib/booking.ts is on, so
tours are free and come in fixed lengths (FREE_TOUR_LENGTHS, 2 or 3
hours) with no payment step; flip it to false to restore paid bookings.
Tips (/guide/review/[id]) and donations (/donate) take card via
Stripe (src/lib/support.ts) and PayPal when the PayPal vars below
are set.src/lib/booking.ts (HOURLY_RATE_CENTS, CURRENCY). Declining a paid
booking auto-refunds.PayPal (optional second payment method for bookings and donations)
/guide/pay/[id]) offering card (Stripe) and, if configured,
PayPal. Leave the PayPal vars blank to show card only./donate and the post-tour tip step offer the same pair. PayPal
donations go through /api/paypal/create-donation + capture-donation
(no sign-in needed) and tips through /api/paypal/create-tip +
capture-tip (signed-in tourist, own finished tour — the rule in
src/lib/tips.ts). Nothing is written to the database — the PayPal
dashboard is the ledger — and the admins (and, for tips, the guide) get
the same emails the Stripe webhook sends for card payments..env.local: PAYPAL_ENV (sandbox or live),
PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET, and
NEXT_PUBLIC_PAYPAL_CLIENT_ID (same value as PAYPAL_CLIENT_ID).PAYPAL_ENV must match the credentials pasted — sandbox keys against
sandbox, live keys against live, or PayPal returns 401 invalid_client. Test with a Sandbox buyer account (Developer dashboard →
Testing Tools → Sandbox Accounts).Single-guide beta mode (optional)
OFFICIAL_GUIDE_ID to that account's auth user id (Supabase
dashboard → Authentication → Users). While it is set, /browse
redirects to /guide, where tourists book that guide (5–15 hour slots)
instead of matching. Unset it to restore the matching experience.ADMIN_EMAILS (comma-separated) — those accounts get an Admin
nav link to /admin to publish availability and approve/decline
requests.GMAIL_USER + GMAIL_APP_PASSWORD (+ optional EMAIL_FROM) for
booking emails (new request → admins; approve/decline → tourist). On the
Gmail account, turn on 2-Step Verification and generate an App
password (Google Account → Security → App passwords); paste it into
GMAIL_APP_PASSWORD. Gmail sends the mail itself, so the @gmail.com
From reaches inboxes without owning a domain. Leave blank locally and
emails are logged to the server console instead.Run
npm run dev
Open http://localhost:3000.
NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY,
SUPABASE_SERVICE_ROLE_KEY, and the three STRIPE_* /
NEXT_PUBLIC_STRIPE_PRICE_ID if you're enabling subscriptions. For the
single-guide beta also add OFFICIAL_GUIDE_ID, ADMIN_EMAILS,
GMAIL_USER, GMAIL_APP_PASSWORD, and EMAIL_FROM. To enable PayPal add
PAYPAL_ENV (live), PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET, and
NEXT_PUBLIC_PAYPAL_CLIENT_ID.Instagram is currently the only live traffic source, and an untagged click from the bio is indistinguishable from direct traffic in GA4. Every bio link, story link and swipe-up must carry:
?utm_source=instagram&utm_medium=social&utm_campaign=<slug>
<slug> names the campaign or post series, lowercase and hyphenated
(hidden-gems, street-food-aug). Keep one slug per series so sessions group.
<NEXT_PUBLIC_SITE_URL>/guide?utm_source=instagram&utm_medium=social&utm_campaign=hidden-gems
Point campaign links at /guide (the page that closes a booking) or / — both
are public, so a logged-out follower sees the guide preview rather than a login
wall. To fire a custom GA4 event from a client component, use the track()
helper in src/lib/analytics.ts; it no-ops safely when NEXT_PUBLIC_GA_ID is
unset and must never be passed personal data.
/gallery reads from src/content/gallery.ts — a typed module, not a database
table. Adding photos is three steps and no migration.
1. Drop the originals into a topic folder under marketing/assets/, named
per that folder's README (huaqiangbei-aisle-night-01.jpg). The originals are
gitignored — camera files carry EXIF GPS and full-resolution faces, and git
history is permanent. Before photographing any identifiable person, read
marketing/assets/CONSENT.md: consent is a logged ledger entry, and the
filename carries only the opaque consent ID (…-SBC-2026-08-001-consented.jpg),
never a name.
2. Convert them. This resizes the longest edge to 1600px, writes WebP into
public/gallery/ under the 300 KB budget, and prints a paste-ready block with
the measured width/height for each file:
node scripts/img.mjs marketing/assets/huaqiangbei
3. Paste each block into the galleryItems array in
src/content/gallery.ts and fill in alt, title, location, themes, and
optionally caption / featured.
The build is the validation layer. assertGalleryValid() runs when the module
loads, so npm run build fails — rather than production — on a duplicate id,
an empty alt, a missing width/height, or a people: 'consented' item whose
consentRef is not a well-formed consent ID (SBC-YYYY-MM-NNN).
What that does not cover: people is one flag for the whole frame, so a
consented subject photographed in front of recognisable bystanders passes the
build and still contains unconsented faces. Composition is the control there —
"shoot places, not faces", per CONSENT.md. Only fields listed in
PublicGalleryItem are sent to the browser, so consentRef and credit stay
server-side.
A location only gets a filter chip once it has 4 items, so a half-shot neighbourhood stays invisible instead of rendering a chip that leads to two photos. With zero items the page renders its "first batch is being shot" state.
Video is the exception to "commit it": MP4s go to the Supabase Storage public
gallery bucket (≤ 8 MB, ≤ 20 s) and the item carries the absolute URL plus a
poster WebP that does live in public/gallery/.
src/
app/
page.tsx landing
login/ email + password login
signup/ email + password signup
profile/ protected: create / edit profile
browse/ public: discover guides (messaging gated)
guide/ public: guide preview + booking (auth at book)
u/[id]/ public profile detail + review form
messages/ conversations list
messages/[id]/ thread view (client realtime)
pricing/ subscription page (Stripe Checkout)
gallery/ public: real photos, ?loc= server-filtered
auth/confirm/route.ts email-confirmation callback
api/stripe/webhook/route.ts Stripe webhook handler
layout.tsx root layout (header + footer)
components/
Avatar.tsx image / initials fallback
SubmitButton.tsx useFormStatus pending state
StarRating.tsx display + input variants
content/
gallery.ts typed photo library + build-time validation
lib/
avatars.ts public-URL helper
matching.ts score function for browse
stripe.ts Stripe SDK + entitlement helper
supabase/
client.ts browser client
server.ts server client (cookies-bound)
admin.ts service-role client (webhook only)
proxy.ts session refresh in src/proxy.ts
proxy.ts runs on every request
scripts/
img.mjs founder photos -> WebP in public/gallery/
supabase/
migrations/ run in order in the SQL editor
Note: Next.js 16 renamed middleware to proxy. The file is
src/proxy.ts, exporting aproxyfunction. Same purpose as the old middleware. Production build must use webpack ("build": "next build --webpack"in package.json) — the default Turbopack output skips build-traces that Vercel's adapter needs to register serverless functions.