リリースノート

新着情報

IdolSajuのすべての変更を透明に記録 — 何を、いつ、なぜ作ったか。

v0.6.11

Dependency refresh — Next 16.2.6, @google/genai 2.0, Tailwind 4.3 + blog hreflang

Routine dep refresh + AI SDK major bump + a blog SEO fix from the parallel session. @google/genai jumped 1.52 → 2.0.1 (major) — verified via the official CHANGELOG that breaking changes are scoped to the Interactions API (Live conversations) which we don't use; tested generateContent + generateContentStream with Gemini 2.5 Pro locally before pushing. Tailwind 4.2.4 → 4.3.0 minor — no design changes needed, dev server compiles clean. Blog pages now emit hreflang alternates for all published locale versions (was missing — Google was indexing each locale as a separate page without language relation).

@google/genai 1.52 → 2.0.1 (major) — verified backward compatible

Infra

Major version bump for the AI SDK powering all readings (saju, cosmic-question, birthday posts). v2.0 breaking changes are scoped to the Interactions API (Live multi-turn conversations) which we don't use. Tested locally: generateContent returns identical shape (.text, .usageMetadata, .candidates[0].finishReason), generateContentStream iteration pattern unchanged. All call sites in gemini-client.ts work without code changes.

Routine dep refresh — Next 16.2.6, React 19.2.6, Tailwind 4.3.0, +9 more

Infra

Patch/minor bumps: next 16.2.4→16.2.6, react/react-dom 19.2.5→19.2.6, next-intl 4.11.0→4.11.1, stripe 22.1.0→22.1.1, zod 4.4.2→4.4.3, zustand 5.0.12→5.0.13, resend 6.12.2→6.12.3, @fal-ai/client 1.10.0→1.10.1, @biomejs/biome 2.4.14→2.4.15, @types/node 25.6.0→25.6.2. Plus tailwindcss + @tailwindcss/postcss 4.2.4→4.3.0 (minor). Type-check + build green.

pnpm v11 migration — onlyBuiltDependencies + pnpm-workspace.yaml

Infra

pnpm v10 → v11 changed the build-script approval mechanism. package.json `pnpm.onlyBuiltDependencies` extended with @google/genai, protobufjs, sharp (their native bindings need to compile on install). New pnpm-workspace.yaml carries the per-package allowBuilds map. Without this, `pnpm install` would block native modules on fresh machines / CI.

Blog posts emit hreflang for all published locale versions

SEO

Each /[locale]/blog/[slug] page now queries the BlogPost table for all PUBLISHED locale versions of the same slug and emits them as alternates.languages in Next metadata. EN gets the x-default reference (or current locale if no EN exists). Google was treating each locale as a separate page without language relation — now they share authority across locales.

v0.6.10

Bias-mode teaser rewrite (2.3% CTR → ?) + variant attribution

The funnel showed idol-match-bias at 2.3% CTR (9 unlocks on 388 teasers) vs idol-match-soulmate at 7.9% — 3.4× worse on the IDENTICAL teaser pool. Diagnosis: same templates, different name substitution. Soulmate uses anonymous placeholders ('your soulmate') which preserves the curiosity gap. Bias shows the real idol name + visible score, so the existing teasers describe what the user feels they already know. New dedicated IDOL_MATCH_BIAS_INTROS pool with 4 variants × 4 locales focused on what bias users genuinely DON'T know yet: sub-score breakdown (love/friendship/creative split), hidden tension/shadow, timing windows (3 weeks/year sync), and inverted 'what the idol's chart needs from you'. Every teaser_viewed now carries a variant: 'idol-match-bias-v2-N' tag so launchkit can attribute CTR per variant + detect that a copy iteration is actively in flight.

New IDOL_MATCH_BIAS_INTROS pool — 4 hooks × 4 locales

Reading

Four variant families optimized for users who already know the idol: (1) Score-breakdown hook — '{score}/100 hides 3 sub-scores that change the verdict'. (2) Hidden tension/shadow hook — 'The one tension fans never see'. (3) Timing hook — 'The exact 3 weeks per year your chart syncs with {idolName}'s peak'. (4) Inversion hook — 'What {idolName}'s chart says they'd actually need from a partner like you'. Each has 3 sub-sections (main + 2 follow-ups). EN/ES/KO/JA fully written, no English fallback.

pickTeaserIntroDetailed() routes bias to new pool + returns variantId

Reading

pickTeaserIntro now wraps pickTeaserIntroDetailed which returns both the teaser variant AND a stable variantId for analytics ('idol-match-bias-v2-N'). Bias mode (ctx.type === 'idol-match' && !ctx.locked) routes to IDOL_MATCH_BIAS_INTROS; soulmate + group keep using the shared IDOL_MATCH_INTROS pool which works at 7.9% CTR. teaser_viewed events now include variant: variantId when present.

Launchkit funnel-worst-volume insight is copy-iteration-aware

Infra

funnel-snapshot.ts emits a new "Bias-pool teaser-copy iteration" section breaking down v1 (legacy, pre-0.6.10) vs v2-N variant counts. launchkit fetch_metrics parser reads this into per-product variant metrics. The funnel_worst_volume_product insight checks for ≥50 v2 impressions and downgrades priority 1→2 with a mitigation note pointing at 7-14d data accumulation before re-flagging. Generic over product slug — works for future copy iterations on other products too.

v0.6.9

idol-match group tracking + saju coreano SEO + smarter funnel insight

Three quick wins surfaced when verifying yesterday's launchkit insights against the actual data. (1) idol-match-group product showed 29 unlock_visible events but ZERO teaser_viewed — the funnel chain broke at the first hop because the group-mode page never rendered <ReadingTeaserIntro> (its teaser is the live ranking display itself, not a blurred preview). (2) /es/saju-profile ranks pos 8.8 for "saju coreano" with 708 imp / 7.6% CTR, plus three sub-queries already converting strongly (pos 4-6, 13-31% CTR) — title was generic "Calculadora Saju Gratis" instead of leading with the exact-match phrase. (3) Launchkit's funnel_drop_off_hotspot insight kept shouting "headline/CTA copy is the biggest hebel" even though we deployed the sticky CTA bar 48h ago to address exactly that drop.

idol-match-group fires teaser_viewed when locked result lands

Infra

Ref-gated useEffect in idol-match/page.tsx fires teaser_viewed with product="idol-match-group" once per locked-result session, reset when user returns to input. Closes the funnel chain — group ranking reads as one continuous chain (teaser → unlock_visible → unlock_clicked → checkout_opened → payment_succeeded) instead of breaking at hop 0. Side-effect: idol-match-bias 2.8% CTR was masquerading partly because of this gap.

/es/saju-profile rewrites for "saju coreano" (708 imp/month)

SEO

Title now leads with exact-match "Saju Coreano Gratis" (was generic "Calculadora Saju Gratis"). Description front-loads "calculadora de saju coreano gratis online" + 30s value prop. Keywords expanded with all four high-CTR query variants ("saju coreano calcular" pos 4.3 30.8% CTR, "saju coreano calculadora" pos 5.7 13.1%, "saju coreano online gratis" pos 4.4 29.7%). Goal: pos 8.8 → top-3 = 2-3× clicks.

Launchkit funnel-drop insight is mitigation-aware

Infra

When metrics show funnel_sticky_visible ≥ 50 (sticky CTA bar deployed and gathering data) and the biggest leak is teaser→unlock_visible, priority drops from 1 to 2 and a mitigation note lists the sticky bar's visible/clicked/paid counts plus a pointer to watch sticky_bar_lift insight before rewriting teaser copy. Other projects without sticky data keep the original priority-1 behavior.

v0.6.8

Sticky-bar deal hook + server-side payment tracking

Two-day data on the v0.6.7 sticky bar showed 132 impressions, 7 taps (5.3% — below benchmark), zero attributed conversions. Hypothesis: the bar's bare 'Unlock Reading' label has no psychological hook. v2 puts the 1+1 deal phrase ('Buy 1, Get 1 FREE' / '1+1 무료' / '1+1無料' / '1+1 GRATIS') as the headline above 'Unlock Reading', so the user sees the bonus before the action. Events tagged variant: 'v2_deal_hook' for A/B comparison. Plus: Stripe webhook now writes payment_succeeded events server-side (flow=webhook) — closes the tracking gap where redirect payments (KakaoPay, NaverPay, Klarna, iDEAL) credit the user but never fire the client-side track because the user didn't return to the page. Funnel-snapshot dedupes by paymentIntentId across client + webhook events.

Sticky bar v2: 1+1 deal hook over the action label

Design

Two-line layout in the pink sticky bar — small uppercase deal phrase ("BUY 1, GET 1 FREE" / "1+1 무료" / "1+1無料" / "1+1 GRATIS") on top, "Unlock Reading" below, price badge + chevron unchanged. Tracks variant: 'v2_deal_hook' on sticky_unlock_visible and sticky_unlock_clicked so we can compare conversion vs v1 (events without variant).

Stripe webhook fires payment_succeeded server-side

Infra

New trackPaymentWebhook() helper in /api/stripe/webhook. On every payment_intent.succeeded event we write an AnalyticsEvent with flow='webhook' + paymentIntentId + currency + method + pack. Catches the ~9-event gap between Stripe (25 charges/30d) and funnel (16 events) — those were Korean redirect payments (8940 KRW = 6× try pack) where the user never returned to the page after KakaoPay/NaverPay/Toss.

Funnel-snapshot dedupes payment_succeeded by paymentIntentId

Infra

When client AND webhook fire for the same PI we keep the client event (richer metadata: viaStickyBar, flow=express_top vs inline). When only webhook fires we keep that. When only client fires we keep that. Drops duplicates so the count matches Stripe exactly.

v0.6.7

Mobile sticky CTA bar + sticky-bar attribution tracking

The biggest funnel leak isn't the unlock card itself — it's that 79% of users (1,220 teaser_viewed → 255 unlock_visible) never scroll down far enough to see the buy button at all. New mobile-only sticky bar pinned to the bottom of the viewport rescues those users: it appears the moment the unlock card scrolls out of view, tap smooth-scrolls back to the card. Built with full attribution: a viaStickyBar flag flows through unlock_clicked → checkout_opened → payment_succeeded so we can compare conversion rate of rescued users vs organic scrollers and know whether the bar creates lift or just shifts conversions. Launchkit insights.py extended with a sticky_bar_lift insight that auto-flags positive lift / negative lift / zero-conversion scenarios in /init.

Mobile sticky CTA bar with safe-area + Apple Pay deep-link

New

Pinned to the bottom of the viewport on mobile (md:hidden), respects iPhone safe-area-inset-bottom. Pink gradient + 3px black border + spinning sparkle icon + price badge + chevron. Visible only when: card off-screen AND user needs to pay AND no modal open AND no payment in flight. Tap → smooth scroll-into-view (block: 'center') so the user sees the full unlock card with pack picker + wallet button. Pure scroll-rescue, not a hijack.

Sticky-bar attribution: viaStickyBar flag flows through to payment

Infra

New cameViaStickyRef in StardustUnlock flips true on sticky tap, gets included as viaStickyBar metadata on every downstream event: unlock_clicked, checkout_opened, payment_succeeded (across all 4 flows: inline, express_top, express, redirect). Express path uses a getViaStickyBar callback prop on the child components. Lets us compute true lift: % of sticky-tappers that paid vs % of all unlock-clickers that paid.

Funnel-snapshot script + launchkit parser extended for sticky bar

Infra

scripts/funnel-snapshot.ts now emits a 'Mobile sticky CTA funnel' section: visible/clicked counts plus 4 attribution lines (→ unlock_clicked / → checkout_opened / → payment_succeeded / baseline conv %). Launchkit fetch_metrics parser reads all 6 fields into the metrics DB. New sticky_bar_lift insight in launchkit/scripts/insights.py wired into insights_for() — auto-shows in /init with verdict (✅ positive / ⚠️ negative / ≈ neutral / 0-conv warning).

Continuous viewport observer instead of one-shot

Infra

The IntersectionObserver on the unlock card was previously one-shot — fired unlock_visible once then disconnected. Now it stays mounted and continuously updates a cardInViewport state used by the sticky bar. Same observer fires unlock_visible exactly once per mount (visibleFired ref) so funnel tracking is unchanged.

Lint cleanup: a11y button types, optional chains, unused imports

Infra

Cleared 13 biome lint errors in stardust-unlock.tsx and funnel-snapshot.ts: 3× useButtonType (added type="button" to non-submit buttons), 3× useOptionalChain (replaced (x ?? {}).y with x?.y), noUnusedImports/Variables, organizeImports, useless empty fragment. No behavior change — pre-existing errors that had been deferred.

v0.6.6

Hooks-rules fix, success screen restored, guest credits actually migrate

Three production bugs from the v0.6.0 → v0.6.5 express checkout rollout. (1) An InlinePaymentForm component had its newer hooks declared AFTER the early returns for phase==='succeeded'/'failed' — React threw 'Rendered fewer hooks than expected' the moment a payment succeeded. Moved 11 hooks above the returns. (2) The Apple Pay flow was auto-calling onUnlock() on success, skipping the create-account screen guests need to claim their credits. Now opens the modal in 'succeeded' phase via a new initialPhase prop on InlinePaymentForm. (3) Magic-link signup credited a brand-new user balance, leaving the guest's paid credits orphaned. NextAuth events.signIn now reads the saju_guest_id cookie and runs migrateGuestCredits immediately. Plus both express paths call /api/checkout/confirm now (was only in card flow → on localhost without Stripe webhook, Apple Pay payments left the guest balance at 0). And the silent .catch(() => {}) on the confirm fetch is gone — failures now log to console.

Hooks moved above early returns — payment success no longer crashes

Fix

Eleven hooks (engagedRef, handlePaymentChange, loadedRef, handlePaymentReady, expressAvailable, showPaymentMethodsLocal, setShowPaymentMethods, expressVisibleFiredRef, handleExpressReady, handleExpressClick, handleExpressConfirm, handleTogglePaymentMethods) were declared after the if (phase === 'succeeded') / if (phase === 'failed') early returns. Once a payment succeeded the render skipped them, React's hook count check tripped, the page crashed.

Apple Pay success now lands on the create-account screen

Fix

Earlier handleTopExpressSuccess polled credits and called onUnlock() directly. Guests paid via Apple Pay never saw the email-collection prompt, so their credits were stranded on a guest cookie they'd lose. New initialPhase prop on InlinePaymentForm lets the parent open the modal pre-set to 'succeeded' — same create-account UI the card flow has always shown.

Guest credits now migrate the moment the user signs in

Fix

NextAuth events.signIn handler reads the saju_guest_id cookie and calls migrateGuestCredits(guestId, user.id) directly. Earlier this only ran when the unlock card later mounted and called /api/credits — if the user navigated to settings instead they saw a stale 0 balance.

Both Express paths now confirm the payment client-side

Fix

The card flow has always called /api/checkout/confirm after success as a fallback for the Stripe webhook. The Express paths (top-level wallet button + modal wallet) didn't — on localhost without webhook configured, Apple Pay payments left the guest balance at 0. Both paths now fire confirm too, idempotent with the webhook in production.

Silent confirm errors now log to console

Infra

The .catch(() => { /* webhook will handle as backup */ }) pattern hid every confirm failure. Replaced with explicit console.error so the dev console actually shows when /api/checkout/confirm returned a non-2xx. Three call sites updated (card flow + both express flows).

Funnel-snapshot script outputs Payment Flows + Express funnel sections

Performance

scripts/funnel-snapshot.ts now emits a 'Payment flows (split by metadata.flow)' block and a 'One-click (top-level Express) funnel' block. Launchkit's fetch_metrics parser was extended to read both — daily-check / insights now surface express_top click-rate and wallet-vs-card revenue split.

Birth-date selector: tap-anywhere opens the drawer

Design

Year/month/day/hour fields used to be free-text inputs with a tiny chevron button at the right edge. On phones the chevron tap target was too small — most taps just opened the keyboard. Now the entire field is a button that opens the drawer/popover. Removed typing mode entirely.

v0.6.5

Skeleton overlay actually visible + covers currency switch too

v0.6.4 added a skeleton overlay during pack-switch but it had two bugs. (1) The overlay used absolute inset-0 over a parent that collapsed to 0 height when Elements unmounted — so the overlay had no visible area. Fix: min-h-[55px] on the wrapper keeps it at the wallet button's height even with no inner content. (2) Currency switch in the modal also wipes clientSecret and recreates the PI, but didn't trigger the skeleton flag. Fix: setIsPackSwitching(true) on currency switch too. Also extended the overlay condition to fire whenever clientSecret is null (covers both cases + first PI fetch).

Skeleton overlay now actually visible during reinit

Fix

Added min-h-[55px] to the wallet wrapper. The pulsing skeleton now occupies the same area as the future Pay button so users see a controlled loading state instead of a collapsed empty area.

Currency switch triggers the same skeleton

Fix

Currency-picker click in the modal now sets isPackSwitching too. The wallet area shows the skeleton during the brief gap when clientSecret is wiped + new PI is being fetched.

v0.6.4

Skeleton overlay during pack-switch remount

v0.6.3 fixed the wallet-amount race by awaiting the server PI update before remounting the Stripe Elements tree. Functionally correct, but the user sees the Apple Pay button briefly disappear (~500ms) while Stripe re-initialises. v0.6.4 adds a pulsing skeleton overlay positioned absolute over the wallet area while the remount is in flight — Express button stays visually 'present' as a loading state instead of vanishing into empty space. The overlay clears in TopLevelExpressCTA's onChecked callback (fires after every remount, not just first mount).

Pack-switch shows a skeleton instead of empty space

Performance

New isPackSwitching state set true at the start of handleSwitchPack, cleared in onChecked once the new ExpressCheckoutElement reports ready. Renders a pulsing semi-transparent block over the wallet area during the gap.

v0.6.3

Fix: pack-switch wallet amount (take 3 — await server update)

v0.6.2 added a key={`${selectedPack}-${currency}`} to <Elements> to remount Stripe on pack switch. The remount worked, but it raced with updatePaymentIntent (which is async): selectedPack flipped immediately, the key changed, Elements remounted, Stripe re-read the PI — but the server-side PI update was still in-flight, so Stripe re-read the OLD amount. Wallet sheet still opened with the wrong total. Fix: handleSwitchPack now awaits updatePaymentIntent BEFORE setting selectedPack. The remount only triggers after the server confirms the new amount — Stripe re-reads the correct value, wallet sheet opens with the right total.

Await server PI update before remounting Elements

Fix

Previously: setSelectedPack ran immediately after track() so React re-rendered before the fetch landed. Now: await updatePaymentIntent first, then setSelectedPack. ~200ms delay between dropdown click and chip update, but the wallet sheet now always opens with the correct amount.

v0.6.2

Fix: pack-switch wallet amount (take 2 — re-mount Elements)

v0.6.1 tried fixing the wallet-amount-on-pack-switch bug with elements.fetchUpdates(), but that only refreshes the PaymentElement state — ExpressCheckoutElement keeps its own internal cache for the amount it shows in Apple Pay / Google Pay sheets. v0.6.2 fixes it properly by adding a `key` to the <Elements> instance that combines selectedPack + currency. When either changes the whole Elements tree re-mounts, ExpressCheckoutElement re-initialises and re-reads the PI from the server — wallet sheet now always opens with the correct amount. Trade-off is a brief ~200ms re-init flicker on pack switch which is acceptable vs charging the wrong amount.

Re-mount Elements on pack/currency change

Fix

Added key={`${selectedPack}-${currency}`} to <Elements>. Forces a full remount of the Stripe Element tree when the user switches pack so ExpressCheckoutElement cannot show a stale amount. fetchUpdates() approach kept as a defensive backup.

v0.6.1

Fix: wallet sheet shows correct amount after pack switch

Hotfix for v0.6.0. When a user picked a different pack (e.g. 5 credits ₩4,900) from the inline dropdown and then tapped the Apple Pay / Google Pay button, the wallet sheet still showed the old amount (₩1,490 for the default 1+1 pack). The PaymentIntent was updated server-side correctly, but the Stripe ExpressCheckoutElement caches the amount client-side. Now calling elements.fetchUpdates() whenever the selectedPack prop changes — Stripe re-reads the PI and the wallet sheet shows the new total. 400ms delay before the call so the server update lands first.

fetchUpdates() on pack switch keeps wallet amount in sync

Fix

TopLevelExpressCTA now receives selectedPack as a prop and calls Stripe elements.fetchUpdates() whenever it changes (after first mount). The wallet sheet now opens with the actual selected pack price, not the cached default.

v0.6.0

One-click checkout: Apple Pay, Google Pay, Link as the primary CTA

Major checkout overhaul. The unlock card now leads with a real one-click wallet button (Apple Pay / Google Pay / Link / PayPal) instead of a generic 'Reveal' button. Users tap the wallet button, confirm with Face ID or Touch ID, and the reading streams in — no modal, no form, no second click. Falls back to a styled 'Pay' button (which opens the modal) on browsers without a supported wallet. The deal info (1+1 BUY 1 GET 1 FREE €1.49) is now a real inline dropdown — picking a different pack updates the wallet button's amount in-place. The 'READING READY' framing + cleaner pink container give the whole CTA more urgency. Funnel events split the new flow so we can measure each step.

One-click wallet checkout (Apple Pay / Google Pay / Link)

New

Stripe ExpressCheckoutElement replaces the Reveal button when a wallet is available on the device. PaymentIntent is pre-fetched on mount so the wallet sheet opens instantly on tap. After payment confirms, credits are polled and the reading auto-unlocks — no second click.

Inline pack-picker dropdown — no modal needed for pack switch

Design

The deal chip (1+1 BUY 1 GET 1 FREE €1.49) is now a real <select>-style dropdown. Tap it → 3 packs slide down inside the unlock card. Picking a different pack updates the PaymentIntent in-place; the wallet button reflects the new amount immediately. Modal is now only for "I want to pay with card" cases.

Pink "deal zone" with READING READY hook

Design

New unlock-card layout: pulsing READING READY indicator + "Your Reading is Waiting" headline + subline + dropdown + Pay button + trust badges + readings counter, all inside a single pink edge-to-edge container with the brutalist black border. Loss-aversion framing ("your reading is already prepared, claim it") boosts urgency without being aggressive.

Trust badges fixed: "Instant Access" replaces "100% Money-Back"

Design

The old badge promised refunds we never issue (project rule: never refund) — that was misleading advertising and chargeback-bait. New badge "Instant Access" reflects the actual product: digital reading delivered immediately, no waiting.

Slimmer FREE callout + relocated trust cluster

Design

FREE signup link is now the same height as the dropdown chip (40px) so primary→secondary→tertiary hierarchy reads correctly. Trust badges + readings counter moved INSIDE the pink zone right under the Pay button — payment + trust signals stay together.

Skeleton loader matches final layout

Performance

While Stripe initializes (~500ms), a skeleton with the same shape as the upcoming layout shows so there's no layout shift. Held minimum 500ms even on cached loads to avoid a 50ms flash. 4s fallback timeout — if Stripe doesn't respond, gracefully shows the Pay button.

Funnel events for the express flow

Infra

New events: express_top_visible (wallet rendered), express_top_clicked (user tapped wallet button), payment_form_loaded (Stripe finished mounting), payment_engaged (user touched the form), pack_picker_expanded, payment_methods_expanded. Funnel-snapshot.ts registers them all so launchkit picks them up automatically.

unlock_visible IntersectionObserver lowered + click fallback

Fix

Earlier the threshold of 0.5 (50% of card in viewport) never fired for tall unlock cards on small mobile viewports — that produced unlock_clicked > unlock_visible, a logically impossible drop-off. Now threshold 0 + rootMargin fires on any pixel in view, and handleClick has a fireVisible() fallback so the invariant visible ≥ clicked always holds.

v0.5.2

Funnel-tracking accuracy: visible ≥ clicked, plus form-load split

First 24h of the new drop-off events surfaced two issues. (1) unlock_visible (113) was lower than unlock_clicked (187) — physically impossible: the IntersectionObserver threshold of 0.5 never fired for the tall unlock card on small mobile viewports. Threshold lowered to 0 + rootMargin so any pixel in view triggers, plus handleClick now fires unlock_visible as a fallback so the invariant visible ≥ clicked always holds. (2) checkout_opened → payment_engaged drops 66% but we couldn't tell whether the form was loading too slowly or users were choosing not to engage. New payment_form_loaded event (PaymentElement.onReady) splits that drop into two interpretable steps: opened → loaded (load-time leak) and loaded → engaged (intent leak).

unlock_visible threshold lowered + click-fallback ensures visible ≥ clicked

Fix

Threshold was 0.5 (50% of card in viewport) — never fired for tall unlock cards on small mobile viewports. Now threshold 0 fires on any pixel in view, and handleClick has a fireVisible() fallback so the click can never beat the observer. Without this, the funnel-snapshot script's drop-off analysis was reporting a phantom 90% leak on a logically-impossible step.

payment_form_loaded event — splits checkout-to-engaged drop

New

Stripe PaymentElement.onReady fires once when the form has fully mounted and is interactive. Combined with the existing checkout_opened and payment_engaged events, the 66% drop from opened-to-engaged now splits into 'didn't wait for load' vs 'saw form, didn't try'. Different fixes for each diagnosis.

funnel-snapshot.ts registers the new event

Infra

Added payment_form_loaded to the event-name filter and the per-product display order in scripts/funnel-snapshot.ts. Launchkit picks it up automatically on the next /init via fetch_metrics → insights.py.

v0.5.1

Retire /daily, ship daily-fortune as the only daily product

The legacy /daily route shipped early as a quick-win free check, but /my-fortune evolved into the real daily product (full Saju-tagged daily reading with cross-sell). Two routes for the same idea was confusing for users and fragmented our SEO signal across both URLs. /daily is now permanently redirected to /my-fortune so search-engine traffic and any external links keep working, the home page free-product card and footer link point to /my-fortune, and the sitemap drops the dead URL.

/daily → /my-fortune permanent redirect

SEO

Added 308 redirect in next.config.ts. GSC will transfer the /daily index signals to /my-fortune. The route file is removed entirely so no stale page renders.

Home page + footer point to /my-fortune

Design

Removed the duplicate "Daily Check" tile from the free-products section on the home page and the duplicate footer link. Single CTA per concept — less decision fatigue.

Internal analytics scripts checked in

Infra

scripts/credit-buyers-journey.ts, page-views-24h.ts, page-views-compare.ts, test-ranking.ts, snapshot-misc.ts, and the cover-image fixers are now in the repo so any future Claude session can re-run them without re-deriving them from scratch.

v0.5.0

Drop-off diagnostics: see exactly where readers bounce

Last 7 days had 2,767 visitors and only a handful of paid readings — the funnel was leaking somewhere between teaser and unlock, and between checkout and payment, but we couldn't tell where. Two new analytics events (unlock_visible via IntersectionObserver, payment_engaged via Stripe PaymentElement.onChange) split each mystery drop into two interpretable steps. Now we can distinguish 'user bounced before scrolling to the unlock CTA' from 'user saw the CTA and didn't click', and 'user opened checkout but didn't engage with the payment form' from 'user engaged but didn't submit'. The funnel-snapshot script now shows each step as % of teaser views so the biggest absolute losses jump out at a glance.

unlock_visible event — separates scroll-bounce from copy-bounce

New

IntersectionObserver fires unlock_visible once per session when ≥50% of the unlock card enters the viewport. Combined with the existing teaser_viewed and unlock_clicked, the 16.5% click-rate on saju-profile (last 7d) splits into two diagnoses: how many people actually saw the CTA vs. how many saw it and bounced. Without this we were guessing.

payment_engaged event — distinguishes friction from abandonment

New

Stripe PaymentElement.onChange fires payment_engaged once per checkout open with the chosen method (card, kakao_pay, paypal, etc.). Tells us if users who opened checkout actually interacted with the form. Saju-profile dropped 51 → 2 from checkout_opened to payment_succeeded last week — this event splits that into "didn't engage" (form/UX problem) vs. "engaged but didn't submit" (method/redirect problem).

Funnel-snapshot script now shows % of teaser per step

Performance

scripts/funnel-snapshot.ts annotates every product-funnel step with its percentage of teaser_viewed, so the biggest absolute losses are visible at a glance instead of buried in raw counts. New scripts/funnel-7d.ts script gives a session-level rolling 7-day funnel with locale split for Saju Profile (ES vs. EN vs. KO/JA) — exactly the format we use for daily check-ins.

Tracking is now product-tagged consistently

Infra

Every payment-related event (checkout_opened, payment_engaged, payment_submitted, payment_succeeded, payment_failed) carries the product slug so the funnel can split per-product without "unknown" buckets. Scripts ignore irrelevant noise and surface what matters per reading type.

v0.4.0

Three more reading teasers, pSEO bridge, deeper funnel tracking

Personalized teasers now run on six readings instead of three — Tarot, My Compatibility, and Yearly Reading joined Saju Profile, Horoscope, and Idol Match. The three highest-traffic SEO landing pages (height, MBTI, groups) finally have a reading-funnel CTA bridge so the ~425 visitors per week who used to land and leave now see a clear way into a reading. Behind the scenes the analytics tagging is fully consistent (no more "unknown" product events), redirect-based payments (Kakao Pay, iDEAL, PayPal) finally fire payment_succeeded so the funnel doesn't undercount real revenue, and a small DB-query helper script (scripts/funnel-snapshot.ts) lets us pull per-product CTR without opening the Vercel dashboard.

Personalized teaser on Tarot, My Compatibility, and Yearly Reading

New

Three more readings join the teaser experiment. Tarot weaves the spread type ("three-card spread", "Celtic Cross") and card count into a punchy intro, locked sections cover card-by-card breakdown / hidden influences / lucky timing. My Compatibility uses the partner's name and the score directly. Yearly Reading personalizes by day master, target year, and zodiac animal of the year, with a 12-month structured ToC underneath. Five rotating variants per locale (EN/ES/KO/JA), one randomized pick per visit.

pSEO → reading-funnel CTA bridge on the top three SEO landing pages

New

The /height/[cm], /mbti/[type], and /groups/[slug] pages bring hundreds of visitors per week from Google but had effectively zero conversion into a reading because the only CTA sat at the bottom of a long idol list. New brutalist gradient block sits prominently after the H1 with two buttons — "Reveal My Saju" + "Find My Idol Match" — context-tagged with the page topic ("165cm idols", "INFJ idols", "BTS"). Tracking fires pseo_cta_clicked so the bridge effectiveness is measurable.

Redirect-based payments now fire payment_succeeded

Payments

Kakao Pay, iDEAL, PayPal, Klarna, Amazon Pay all confirm payment by redirecting back to our page with redirect_status=succeeded in the URL. The inline Stripe flow fired payment_succeeded immediately, but the redirect-return path skipped it — so the funnel was undercounting real revenue by roughly two-thirds. Now both flows tag with `flow: "inline" | "redirect"` so the two paths stay separable in analytics.

Analytics product-slug consistency across the funnel

Infra

Twelve StardustUnlock callsites (mbti-reading, tarot, palm-reading, yearly-reading, my-fortune, my-compatibility, blood-type-reading, spirit-animal, seimei-handan, cosmic-questions, soulmate-sketch, settings) had no product prop — their unlock_clicked / checkout_opened / payment_succeeded events showed up as product=unknown in analytics and were uncountable per reading type. All twelve are now tagged. Plus the idol-match teaser_viewed event is aligned with the downstream events (uses idol-match-soulmate / idol-match-bias suffix) so the funnel reads as one continuous chain per sub-mode instead of breaking at the first hop.

scripts/funnel-snapshot.ts — per-product CTR from a single command

Infra

Quick DB query against the AnalyticsEvent table that prints event counts and per-product funnel since the last release. Removes the dashboard round-trip when checking "how is the new variant doing" — also feeds the LaunchKit cross-project analyzer (events_sync.py) for daily reports.

Dependencies refresh

Infra

@google/genai 1.50.1 → 1.51.0, @stripe/stripe-js 9.3.1 → 9.4.0, zod 4.3.6 → 4.4.2, @biomejs/biome 2.4.13 → 2.4.14.

v0.3.0

Personalized teasers, full funnel tracking, simpler payment routing

Three high-traffic readings — Saju Profile, Horoscope, and Idol Match — now show a personalized teaser before the unlock CTA: a numbered first section with the user's name and chart facts woven in, two locked accordion cards mirroring the real reading layout, and a compact "+ N more sections" hint. The idol-match teaser also has a soulmate-mode variant that doesn't spoil the #1 match. Behind the scenes we wired the entire conversion funnel into Vercel Custom Events (teaser_viewed → unlock_clicked → checkout_opened → payment_succeeded → reading_completed), so we can measure whether this experiment actually moves the needle. Plus the Stripe payment-method routing got rebuilt around URL locale instead of a drifting cookie, which fixes the "Korean methods on /en pages" failure pattern that was killing ~14% of attempted payments.

Personalized teaser on Saju Profile, Horoscope, and Idol Match

New

Unauthenticated visitors now see a personalized 3-section preview before the unlock CTA — section 1 fully visible (with name + day master / sun sign / matched idol baked in via slot replacement), sections 2 and 3 collapsed with a lock icon, then a compact "+ N more sections in your full reading" hint that lists the remaining ToC items. Five rotating variants per locale (EN/ES/KO/JA), one randomized pick per visit. After unlock the user gets the full personalized AI reading, so the teaser sets accurate expectations without spoiling content.

Soulmate-mode teaser hides idol identity

New

On /idol-match in soulmate mode (find your #1 match), the teaser still appears above the locked match cards but with idol-identifying slots replaced by anonymous placeholders — "your soulmate" instead of the idol's name, "the universe" for the group, "??" for the score. The text reads naturally and communicates the full reading depth without revealing which idol the user is about to unlock.

Soulmate-mode locked chart preview

New

Below the ranking list, soulmate mode now also shows the same Score Card / Element Synergy / Manseryeok layout that appears post-reveal — but with the idol's photo, name, group, score, level, and pillars all blurred. Users see exactly what they get (chart side-by-side, harmony summary, score box) without seeing who.

Full conversion funnel in Vercel Custom Events

Infra

Every step from page view to paid reading is now tracked: teaser_viewed → unlock_clicked → reading_unlocked_with_credits or checkout_opened → payment_submitted → payment_succeeded / payment_failed → reading_started → reading_completed. Each event carries product (saju-profile, horoscope, idol-match-bias, idol-match-soulmate, idol-match-group) and contextual fields like dayMaster, sunSign, idolName, score, currency, locked-state, so the funnel is segmentable per product and per variant. Events flow into both Vercel Analytics (dashboard) and our own /api/events log (DB).

Stripe payment-method routing rebuilt around URL locale

Payments

Stripe used to pick the Korea PaymentMethodConfiguration (Naver Pay / Kakao Pay / Payco) whenever the saju_currency cookie was KRW — even on /en/* pages, which left non-Korean users staring at unfamiliar Korean methods and 14% of attempted payments expiring at "payment_intent_payment_attempt_expired". Routing is now driven by URL locale instead of cookie currency: /ko/* uses the Korea PMC, every other locale uses the Default PMC. Locale-mismatch payment failures should drop to near-zero.

Locale stops being forced from currency in checkout

Payments

The client used to override locale to "ko" whenever currency was KRW, which corrupted the locale field in Stripe metadata (a /en/horoscope checkout would log as locale=ko). Locale now follows URL strictly — analytics and Stripe metadata stay consistent.

Reveal-Saju button visually larger on the entry form

Reading

The primary CTA on /saju-profile (Reveal Saju Profile) was visually outweighed by the form fields above it. Padded up to py-5 with text-xl font and a 5px brutalist shadow so it dominates the form like a real conversion button should.

Reusable teaser module for future reading types

Infra

Teaser intros, locked-section titles, section meta (icon + label + score), and slot-filling logic all live in /lib/teaser-intros.ts. Adding a new reading type (MBTI, Tarot, Yearly, Compatibility, …) means extending the discriminated TeaserContext union and adding 5 variants × 4 locales — no UI changes needed. The component picks up new product types automatically via the type discriminator.

Dependencies refresh

Infra

@stripe/react-stripe-js 6.2.0 → 6.3.0, lucide-react 1.11.0 → 1.14.0, next-intl 4.9.1 → 4.11.0, @mediapipe/tasks-vision 0.10.34 → 0.10.35.

v0.2.0

1+1 anchor pricing, contact form, and the release system itself

Pricing rework focused on a single hero deal — the smallest pack now carries a permanent 1+1 bonus while the larger packs sit clean as anchors, the way a CU 1+1 deal works. Reading-unlock CTA stops shouting prices at the entry point. Plus a real contact form (with a brutalist confirmation email), a public release page so you can see what we ship, and footer links to the SEO categories that were previously hidden.

1+1 deal lives on the smallest pack only

Pricing

Bonus credits no longer dilute every pack. The €1.49 try pack stays "buy 1, get 1 free" forever, while the 5- and 15-credit packs are plain volume options. The bonus regains anchor power because the other packs don't copy it.

Reading-unlock CTA leads with the deal, not the price

Pricing

The "Reveal Full Reading" button used to expose a per-credit "from €0.49" anchor that read as expensive in some currencies and abstract in others. It now shows a brutalist "1+1 Buy 1, get 1 free" chip right under the title — value framing instead of unit pricing at the conversion point.

Checkout banner cleaned up

Design

Removed the 48-hour countdown timer (a permanent deal needs no urgency) and the duplicate tilted "1+1" sticker that was competing with the try-pack badge. Single bonus marker per element now.

Long button labels no longer overlap prices in KRW

Fix

On Korean Won prices like ₩495, the unlock button text would visually crash into the price tag. Button content now stacks vertically with proper wrapping so labels and chips never collide, in any currency.

Real contact form at /contact

New

Replaces the scattered (and partially non-working) privacy@/dmca@/legal@/support@/contact@idolsaju.com mailto links with a single brutalist form in EN/KO/JA/ES. Topic dropdown covers general questions, privacy, DMCA, billing, bugs, and image attribution. Honeypot anti-spam baked in.

Brutalist confirmation email

New

Every contact submission triggers an instant confirmation in the user's inbox — yellow IdolSaju background, white card with black borders and hard shadow, brand chip, status sticker, summary of their submission, and a CTA back to their language's homepage. Localized in 4 languages.

What's new — public release page

New

New /releases page (linked in the footer as "What's new") lists every IdolSaju update with category chips and dates. This very entry is the first one visible. Built with the same brutalist look as the rest of the site.

Footer links to Numerology, Angel Numbers, Crystals, Chakras

SEO

The Explore footer column now points into the previously orphaned SEO categories (~256 pages with no internal links from the global nav). Each link leads to a representative entry that cross-links to its siblings, so Google has an actual entry path.

Live readings counter on the unlock card

New

A subtle "✦ 1,247 readings unlocked this week" line now appears under the trust badges, showing real activity from the last seven days. Hidden when the count is too low to be impressive — no fake numbers.

Library updates and Stripe API bump

Infra

Pulled minor/patch updates across Stripe, Prisma, Biome, lucide-react, Cloudinary, and the Stripe JS SDKs. Stripe API version moved to 2026-04-22.dahlia. Tests and build green throughout.

Dead teaser code removed

Infra

Cleared an empty `/api/reading-teaser` folder and corrected our internal documentation: the teaser/preview UI is fully client-side and writes nothing to the database until a paid reading runs. No more phantom "cleanup cron" debt on the backlog.