KlikForm — SaaS form builder platform (Malaysian market). Users create forms, collect responses, generate e-certificates, build QR codes, and shorten URLs.
- Framework: Next.js 16.1.6 (App Router, Turbopack)
- Database: Supabase (PostgreSQL + Auth + RLS)
- Google Integration: OAuth ("Connect with Google") + Manual Service Account keys
- Payments: BCL.my webhook-based payment system
- Styling: Tailwind CSS v4 + shadcn/ui components
- Edge Auth:
proxy.ts(renamed frommiddleware.tsper Next.js 16 convention) - Deployment: Vercel
- Monitoring: Sentry (currently disabled in
next.config.ts)
| Route Group | Path | Purpose |
|---|---|---|
(auth) |
/login, /register |
Auth pages |
(dashboard) |
/forms, /responses, /settings, /certificates, /certificates/builder, /qr-builder, /shortener |
Main dashboard (sidebar layout) |
(public) |
/form/[id], /check/[formId], /verify/[id], /s/[code], /privacy, /terms, /about |
Public-facing (no auth) |
api |
/api/auth/google/*, /api/cron/*, /api/payment/webhook, /api/proxy, /api/service-email |
API routes |
builder |
/builder/[id] |
Form builder (outside dashboard layout) |
| Root | /, /pricing, /refund |
Landing, pricing, refund pages |
products |
/products/forms, /products/certificates, /products/shortener, /products/qr-codes |
Individual product feature pages |
actions/— Server actions:forms.ts,certificates.ts,certificate-template.ts,qr-codes.ts,short-links.ts,auth.ts,user.ts,sheets.tslib/storage/— Supabase CRUD:forms.ts,settings.ts,certificates.ts,qr-codes.ts,short-links.ts,subscription.tslib/types/— TypeScript interfaces:forms.ts,certificates.ts,qr-codes.ts,subscription.ts,common.ts,index.tslib/— Utilities:encryption.ts,rate-limit.ts,navigation.ts,email/,constants/,api/components/— UI components:dashboard/(sidebar, form-card),ui/(shadcn),pricing/,pricing-modal.tsxutils/supabase/— Supabase clients:client.ts(browser),server.ts(SSR),admin.ts(service role)
| Table | Purpose |
|---|---|
forms |
Form definitions (fields, settings, theme, is_active, receive_email_notifications, redirect_buttons) |
settings |
Google credentials per user (encrypted) |
subscriptions |
User tier (free/pro/enterprise), status, period |
usage |
Monthly usage tracking (forms created, submissions count) |
certificate_templates |
E-cert builder templates |
qr_codes |
QR code designs |
short_links |
URL shortener data |
transactions |
Payment records (BCL webhook) |
- OAuth ("Connect with Google") — Recommended. One-click auth, stores
googleAccessToken+googleRefreshTokenin settings. Auto-refreshes expired tokens. - Manual Service Account — Advanced. User configures
googleClientEmail+googlePrivateKeyin Settings → Service Account tab. Requires manual Google Sheet sharing.
- Builder logic:
useManualKeys = !!settings?.googleClientEmaildetermines which UI to show. - Google Sheet URL input: Always visible in builder if form has one.
- Blue instruction box: Hidden for manual key users (they already configured in Settings).
- Rate limiting check
- Honeypot detection (
_gotchafield) — silently rejects bot submissions - Server-side validation (with ReDoS protection: 1000-char cap)
- File uploads → Google Drive (if configured)
- Send data → Google Sheets (if
googleSheetUrlset AND credentials exist) — auto-syncs new headers - Increment
usage.total_submissionscounter - Email notification to form owner (fire-and-forget, gated by
receive_email_notifications)
- Certificate Verification (e-Sijil):
checkCertificateByICchecks forgoogleAccessToken(OAuth flow) first with auto-refresh, then falls back to Service Account credentials. - Mobile Certificate Template Fallback (2026-03-18):
getCertificateTemplatePublic()usescreateAdminClient()(service role) so RLS policies don't block unauthenticated visitors.userIdstripped from response to prevent enumeration. - Service Account Google Credentials parsing:
.trim()andformatPrivateKey()applied to prevent trailing space errors. - IC Search Robustness: Google Sheet column regex matches
IC,No IC,Kad Pengenalan, etc. - Certificate Name Formatting: Auto-uppercased in
CertificateTemplatecomponent for consistent ALL CAPS display. - IC Input UX: Removed dashes from placeholder (
901234567890) since dash-formatting isn't required. - Login Layout Optimization: Compact
/loginUI for 14-inch laptops (down to 643px height) without viewport clipping.
- PDF/PNG Capture:
html2canvas-prowithonclonehook moves the off-screen (top: -9999px) element into the cloned sandbox only — no visual flash on user's DOM. - HD Output:
scale: 3for crisp rendering,image/jpegat0.7quality with'FAST'compression for small file sizes. - Sub-pixel White Borders: Forced
scrollY: 0,scrollX: 0and+2overlapping pixels to override rounding gaps. - Portrait Orientation: Dynamically bound
isPortraitevaluations, proportional percentage constraints, shrinking canvas widths for tall elements.
- NaN Input Prevention: Number inputs use
Number(val) || 0fallbacks to preventparseInt("") => NaNcrashes. - Orientation Persistence:
widthandheightadded tohandleSavepayload to persist Portrait mode. - Auto-Save Indicator: Debounced
useEffectshows real-timeMenyimpan...→Tersimpan di awanin sticky header. - Joyride Onboarding: 5-step
react-joyridetour (auto-plays once vialocalStorage). Sticky header dynamically downgraded toz-0while tour active.scrollOffset: 150mapped per-step (V3 requirement).
- Lists all user forms with Google Sheet links and "Open Sheet" / "Create Sheet" buttons.
- "Create Sheet" only for OAuth users (
hasGoogleOAuthprop). CallscreateSheetForFormActionwhich uses OAuth access token + handles refresh. - Builder's blue instruction box hidden for manual key users.
- Dynamic Progress Bar: Top-edge bar + floating
X / Y Terjawabbadge. - Smart Auto-Scroll: Typeform-style scroll-to-next on Radio/Rating selection.
- Live Countdown Timer: If
attendanceSettings.endTimeset, sticky red badge counts down then auto-locks form. - WhatsApp Share: Toggle in Builder (
whatsappShareEnabled+whatsappShareMessage); button on Thank You page usingwa.meAPI. - Honeypot Anti-Bot: Invisible
_gotchafield; submissions with it filled return fake success. - Form Active/Inactive:
is_activeboolean column + Builder toggle. Public form renders "Borang Ditutup" lock screen when off.
- Hydration Mismatch Fix:
mountedstate +value={mounted ? activeTab : 'login'}so server and client render the same initial Radix Tabs value.
- All dashboard routes auth-gated via
getUser()+ RLS (user_idfilter). /api/service-emailrequires authentication, returns 401 if not logged in.submitFormAction: IP-based rate limiting + server-side validation + quota check./api/proxydomain whitelist (Google domains only).- Builder ownership check (
user.id !== form.userId). - Public payloads strip sensitive tokens (
googleSheetUrl: undefinedto public clients). - Sheet Injection Shield:
=,+,-,@prefixed inputs forced to plaintext vialib/api/google-sheets.ts. - ReDoS Protection: text strings capped at 1000 chars before regex.
getCertificateTemplatePublicstripsuserIdto prevent enumeration.qr-codes.ts.ilike()escapes%,_,\.inactivity-check/route.tsdoesn't leakString(error)in response (server-side log only)./api/auth/callbackvalidatesnextparam to prevent open redirects (//evil.com).lib/storage/short-links.tsusesmaxShortLinksfromTIER_LIMITS(free: 5, pro/enterprise: -1).components/certificate-qr-card.tsxuses client-sideqrcode.react(zero external API).- BCL webhook signature verified via HMAC-SHA256.
- Public forms
dangerouslySetInnerHTMLsanitized. - Open Redirects in
proxy.tsvalidated (only relative paths accepted). transactionsRLS policies tightened.- CSP, X-Frame-Options DENY, X-Content-Type-Options nosniff, Referrer-Policy, Permissions-Policy headers in
next.config.ts.
- 2026-02-24 — Loading skeletons, email notification on submission, auto-create Google Sheet (OAuth), Settings/Shortener mobile fixes, hydration suppress, landing page English translation.
- 2026-02-25 — Corporate
/aboutpage, 4 product pages (/products/*), gradient slowmo (8s), descender clipping fix (pb-4), email notification toggle, RLS performance indexes (20260225154753_add_rls_performance_indexes.sql), critical 42P01 fix (production schema sync + triggersearch_pathreset). - 2026-02-26 — Final security release; production build clean, 0 errors.
- 2026-02-28 — Pricing page features synced with pricing modal; plan card layout fix.
- 2026-03-27 — Certificate creation infinite loading fix (Server Action returns structured
{ success, id }JSON; redirect moved client-side viauseRouter().push()). - 2026-04-02 — Dynamic Google Sheet header syncing (auto-append missing headers); certificate verification by Email or IC (auto-detects
@symbol); pricing sync (free tier honestly shows limited Pro features); Pro 50% promo (RM 5 → RM 10); BCL webhook plugged into Resend (getPaymentSuccessEmail,getWelcomeProEmail). - 2026-04-17 — Form Builder Joyride onboarding, drag-and-drop UX polish,
is_activetoggle, honeypot anti-bot, WhatsApp share, auto-save indicator, public form progress bar + auto-scroll + countdown timer. - 2026-05-28 — System cleanup pass:
- ESLint warnings cleared (unused
getSubscriptionimport inlib/storage/short-links.ts;<img>annotation inapp/(public)/form/[id]/client.tsxfor proxied user-uploaded images). - Migrated
middleware.ts→proxy.ts(Next.js 16 file convention). Function renamedmiddleware→proxy. Build now passes without deprecation warning. Earlier failed migration was due to misconfiguration, not platform support —proxy.tsIS supported by Next.js 16.
- ESLint warnings cleared (unused
- Dependency cleanup:
- Removed
@prisma/client(project uses Supabase, Prisma was unused dead weight). - Replaced
radix-uiumbrella package with individual@radix-ui/react-alert-dialogand@radix-ui/react-navigation-menu(better tree-shaking; both files incomponents/ui/updated). All other Radix imports were already individual. - Pinned
nextfrom^16.1.6→16.2.6(exact pin) to control major-framework drift. The version bump also addresses postcss XSS + Next HTTP request smuggling advisories. - Stale CLI output artifacts (
build-log.txt,build_output.txt,lint_output.txt,tsc_output.txt) added to.gitignoreand removed from tracking. (lint_output.txthad been leaking a previous developer's path:C:/Users/wanzo/....)
- Removed
- Test framework: Added Vitest (
npm test,npm run test:watch) withvitest.config.tsand 26 passing tests across 4 suites:tests/tier-limits.test.ts— tier semantic correctness +canCreateMore()gating logic.tests/open-redirect.test.ts—getSafeRedirectPath()rejects//evil.com,https://,javascript:, etc.tests/webhook-signature.test.ts— BCL HMAC verification (timing-safe, tampered body, wrong secret, malformed sig).tests/sheet-injection.test.ts— formula-injection guard (=,+,-,@).
- Rate limiter consolidation: Three duplicated in-memory rate-limit Maps (one in
actions/forms.ts, one inactions/certificates.ts, one inlib/rate-limit.ts) collapsed into a singlelib/rate-limit.ts. New API:await checkRateLimit(ip, RATE_LIMITS.formSubmission, 'form-submit'). Added optional Upstash Redis backend (auto-detected viaUPSTASH_REDIS_REST_URL+UPSTASH_REDIS_REST_TOKEN; falls back to in-memory if@upstash/redisnot installed). Dynamic import via runtime string keeps@upstash/redistruly optional — TypeScript build doesn't complain when uninstalled. - Sentry re-enabled conditionally:
next.config.tsnow wraps withwithSentryConfigonly when bothNEXT_PUBLIC_SENTRY_DSNandSENTRY_AUTH_TOKENare present. Previously was hard-disabled (export default nextConfig) for Vercel build debugging.sentry.{client,server,edge}.config.tsonly callSentry.init()when DSN is set, and use 10% trace sampling in production (was 100%). - README.md: Replaced default Next.js boilerplate with proper docs covering tech stack, env vars, DB setup, scripts, architecture, security posture, and deployment.
- Trigger
search_pathproper fix (supabase/migrations/20260529000000_fix_trigger_search_path.sql): Re-createsgenerate_short_code,handle_new_user,get_email_by_usernamewith explicitpublic.schema-qualified table references ANDSET search_path = ''. This eliminates the Supabase Security Advisor "mutable search path" warning while keeping triggers functional. Apply viasupabase db pushthen runNOTIFY pgrst, 'reload schema'(the migration includes this NOTIFY). - Build state: lint 0 warnings, 26/26 tests pass, production build clean (Next 16.2.6, Turbopack, 41 routes).
- 2 moderate-severity advisories remain (postcss XSS in Next-bundled postcss). Not exploitable in our usage (no user-supplied CSS parsing); awaiting upstream Next patch.
- Fasa B pending (multi-page forms, audit log, respondent email notify, PDPA toolkit). Fasa C pending (custom domain, workspaces, payments-in-form, templates gallery, AI generator, backup).
- Form Analytics Dashboard shipped:
- Migration
supabase/migrations/20260529001000_add_form_events.sql— newform_eventstable trackingview,start,field_focus,submit,abandonevents. RLS enforces owner-only SELECT; INSERTs go through service role. Indexes on(form_id, created_at DESC),user_id, and partial index onvisitor_hash(for unique counts). Includesprune_form_events()SECURITY DEFINER function for 180-day retention. - Privacy: visitor IPs hashed via SHA-256 + daily-rotating salt (
ANALYTICS_HASH_SECRET), so unique-visitor counts are accurate per day but no long-term tracking. User-Agent reduced to coarse device family (mobile/tablet/desktop/bot/unknown). No raw PII stored. - Pure aggregation in
lib/analytics/aggregate.ts(sync, deterministic, fully unit-tested) — kept separate fromactions/analytics.tsbecause Server Action modules require all exports to be async AND can't re-export types under Next.js 16 + Turbopack. - Server actions in
actions/analytics.ts:trackFormEvent(rate-limited 100/min/IP via theanalyticsbucket; uses admin client to bypass RLS for anonymous writes; looks up form owner once and denormalisesuser_idonto each event row);getFormAnalytics(RLS-enforced owner-only fetch + aggregation). - Client hook
hooks/use-form-tracking.ts: stable per-tab session id insessionStorage; firesviewonce on mount,starton first field interaction,field_focus(deduped per field per session),submiton success,abandononpagehideif user never submitted. All calls fire-and-forget — analytics never blocks the form. - Wired to public form via
onFocusCaptureon each field container inapp/(public)/form/[id]/client.tsxandtrackSubmit()after successful submission. - Dashboard at
app/(dashboard)/responses/[id]/analytics/: page (server, ownership check + RLS double-check) + client (4 stat cards, 3 rate cards, 30-day daily chart, devices split, top-8 field engagement). Empty state when no data. - "Analytics" button added to each form card in
app/(dashboard)/responses/client.tsx.
- Migration
- Tests: added
tests/analytics.test.ts— 10 tests covering aggregation correctness (zero state, view/start/submit/abandon counting, unique visitor dedup, conversion + completion rate math, average submit duration, field engagement sort order, device split, daily bucket placement). Total now 36/36 pass. - Build state: lint 0 warnings, 36/36 tests pass, production build clean (Next 16.2.6, Turbopack, ~42 routes).
Five new product features shipped in one pass. All bebas-konflik dengan kerja sedia ada.
lib/types/forms.ts—ConditionalConfigextended withrules[]+logic: 'all' | 'any'. Legacy{ fieldId, value }shape still supported for backward compat (normalized at runtime).lib/forms/conditions.ts— pure evaluator:evaluateConditional(),evaluateRule(),normalizeConditional(). Operators:equals,not_equals,contains,not_contains,is_empty,is_not_empty,gt,lt. Coerces arrays/dates safely. Fails-open (visible) when a rule references a deleted field.components/forms/fields-editor/index.tsx— new<ConditionalLogicEditor>component with multi-rule editor, AND/OR toggle, dynamic operator → value input handling.app/(public)/form/[id]/client.tsx—isFieldVisiblerewired to use the pure evaluator.- Tests:
tests/conditional-logic.test.ts— 17 tests (legacy compat, every operator, all/any logic, missing-field safety, array coercion).
- Migration
supabase/migrations/20260529030000_add_form_webhooks.sql—form_webhooks(id, form_id, user_id, url, secret_encrypted, events[], enabled, last_status, last_error, last_fired_at). Owner-only RLS.updated_attrigger with schema-qualifiedpublic.references andSET search_path = ''(per Security Advisor lesson). lib/types/webhooks.ts—FormWebhookandWebhookSubmissionPayloadtypes.lib/webhooks/dispatch.ts—signPayload,verifySignature(timing-safe),dispatchWebhook(5s timeout, max 3 attempts, exponential backoff, 4xx short-circuits, 5xx + network errors retry). Headerx-klikform-signature(HMAC-SHA256 hex). Unit-test injectsfetchImpl+sleepImpl.lib/storage/webhooks.ts— CRUD:listWebhooksForForm(masked secret),listWebhooksForDispatch(admin client, decrypted, scoped byform_idANDuser_id),createWebhook,updateWebhook,deleteWebhook,recordWebhookResult. Secrets encrypted vialib/encryption.ts(AES-256-CBC).actions/webhooks.ts— Zod-validated server actions:listWebhooksAction,createWebhookAction,updateWebhookAction,deleteWebhookAction,testWebhookAction(single-attempt, 5s timeout for fast feedback). Note:z.ZodErrorexposes.issues[], not.errors[]in zod v4.actions/forms.ts—submitFormActiondispatches enabled webhooks in parallel afterincrementSubmissionCount. Failures never bubble.components/forms/webhooks-card.tsx— Builder UI: list, add (URL + auto-generated 32-hex secret), enable/disable toggle, test fire, secret rotation, delete. Last-status indicator (green/red) + relative timestamp.- Mounted in
app/builder/[id]/client.tsxbetween Attendance card and Form Fields list. - Tests:
tests/webhook-dispatch.test.ts— 9 tests (signature stability, tamper rejection, wrong secret, malformed sig, single 4xx call, 3× 5xx retry, network-error recovery).
- Migration
supabase/migrations/20260529040000_add_response_edit_tokens.sql—response_edit_tokens(token unique, form_id, user_id, submission_id uuid, email, snapshot jsonb, expires_at, used_at). Owner-only RLS for SELECT (dashboard audit). Public path uses admin client scoped by token.forms.edit_link_settingsjsonb column added (instead of three new flat columns). lib/types/forms.ts—EditLinkSettings { enabled, expiryDays, emailFieldId? }.lib/storage/edit-tokens.ts—generateEditToken()(64 hex chars fromrandomBytes(32)),createEditToken(),getEditToken()(returns reason: not_found / used / expired),markEditTokenUsed()(single-use semantics).lib/email/index.ts—getEditLinkEmail(formTitle, editUrl, expiryDays)template (sky-blue gradient, single-use warning).lib/api/google-sheets.ts— addedupdateSheetRow(config, matchColumn, matchValue, data)to update an existing Sheet row by hidden_submission_idcolumn.actions/forms.ts—submitFormActionnow generatessubmissionId = uuidv4()upfront, injectsdbData._submission_id, then (ifeditLinkSettings.enabled+ valid email) creates the token and emails the magic link. Origin resolved fromheaders().originthenNEXT_PUBLIC_APP_URLfallback.actions/edit-response.ts—loadEditableResponse(token)+submitEditedResponseAction(token, formData). UsesupdateSheetRowto rewrite the matched row, then marks token used. Skips file uploads (would orphan previous Drive files), webhooks, and owner-notification email — edits are deliberately quieter than new submissions. Reuses validation rules fromsubmitFormAction.- New route
app/(public)/edit/[token]/page.tsx— re-rendersPublicFormClientwitheditMode={token}+ prefilledinitialValues(re-keyed from label → field id). Renders block-screen card on invalid/expired/used tokens.metadata.robots: { index: false, follow: false }so search engines never crawl edit URLs. app/(public)/form/[id]/client.tsx—PublicFormClientextended with optionaleditModeandinitialValuesprops. Analytics tracking disabled in edit mode (no fakeview/submit). Submit branch picks the right action based on mode.components/forms/edit-link-card.tsx— Builder UI: master toggle, email-field selector (onlytype === 'email'fields), expiry-days input (1-365 clamp). Yellow warning when no email field exists yet.- Mounted in builder right after Webhooks card.
- Tests:
tests/edit-token.test.ts— 6 tests (token format, uniqueness, expiry math, email regex). DB-touching paths (createEditToken, getEditToken) covered by integration in production.
lib/csv/parse.ts— minimal hand-rolled CSV parser. Handles BOM, CRLF, quoted commas, escaped"", embedded newlines, blank lines. ExportsparseCSV()+pickField()(case-insensitive header lookup with candidate aliases).lib/certificates/render.ts— extracted shared rendering helpers fromapp/(public)/check/[formId]/client.tsx:captureToCanvas(el, opts)(html2canvas-pro, scale 3,onclonesandbox trick for hidden element),canvasToPngBlob,canvasToPdfBlob,safeFilename. Reusable across single + bulk flows.app/(dashboard)/certificates/builder/[id]/bulk/page.tsx+client.tsx— new dashboard route. Workflow: upload CSV (5MB cap) → auto-detect column mappings (name/program/date/IC) → user confirms or remaps → choose PNG/PDF → progress bar drives a per-row render-then-zip loop usingJSZip(already in deps). Hidden full-size renderer mounts attop: -9999pxand is captured via the sameonclonesandbox technique. TworequestAnimationFramewaits before capture so React commits + browser paints first.- "Bulk generate" sparkles icon button added to every
<CertificateTemplateCard>linking to the new route. - Tests:
tests/csv-parse.test.ts— 13 tests (empty, simple, CRLF, BOM, blank lines, trailing-empty cells, quoted-with-comma, escaped quotes, embedded newlines, pickField case-insensitive + alias fallthrough + empty cell skip).
lib/analytics/aggregate.ts— addedaggregateUserAnalytics(rows, days)+UserAnalyticsRow/UserAnalyticsSummarytypes. Pure: total views/submits, unique visitors, conv rate, top 5 forms (by submits with views as tiebreaker), 30-day daily totals.actions/analytics.ts— addedgetUserAnalyticsSummary(days)— RLS auto-restricts to caller's own forms; defensivelyeq('user_id', user.id)anyway.components/dashboard/cross-form-analytics.tsx— server component: 4 stat cards (Views, Unique visitors, Submits, Conv rate), 30-day sparkline (CSS-only, hover tooltip), top-3 forms list (each linking to per-form analytics page). Silently renders nothing whentotalViews === 0so empty dashboards stay clean.- Mounted in
app/(dashboard)/forms/page.tsxbetween<DashboardStats>and the page header. - Tests:
tests/cross-form-analytics.test.ts— 6 tests (empty state, counting, top-form ranking + tiebreaker, 5-cap, daily bucket placement, out-of-window events drop from daily but stay in totals).
vitest.config.ts— added'server-only'alias totests/__mocks__/server-only.ts(empty stub) so unit tests can import server-tagged modules without the real package's "RSC only" throw.- New zod usage uses
.issues[](zod v4) not.errors[]. lib/types/index.ts— re-exportsConditionOperator,ConditionRule,EditLinkSettings.
npm run lint— 0 warnings.npm test— 87/87 pass across 10 suites (was 36/36).npm run build— clean, 43 routes (was ~42; +/edit/[token]and/certificates/builder/[id]/bulk).
actions/edit-response.ts
actions/webhooks.ts
app/(dashboard)/certificates/builder/[id]/bulk/client.tsx
app/(dashboard)/certificates/builder/[id]/bulk/page.tsx
app/(public)/edit/[token]/page.tsx
components/dashboard/cross-form-analytics.tsx
components/forms/edit-link-card.tsx
components/forms/webhooks-card.tsx
lib/certificates/render.ts
lib/csv/parse.ts
lib/forms/conditions.ts
lib/storage/edit-tokens.ts
lib/storage/webhooks.ts
lib/types/webhooks.ts
lib/webhooks/dispatch.ts
supabase/migrations/20260529030000_add_form_webhooks.sql
supabase/migrations/20260529040000_add_response_edit_tokens.sql
tests/__mocks__/server-only.ts
tests/conditional-logic.test.ts
tests/cross-form-analytics.test.ts
tests/csv-parse.test.ts
tests/edit-token.test.ts
tests/webhook-dispatch.test.ts
task.md
System Improvements (2026-08-30 — Sijil & E-Cert Auto-Scaling Typography & Canva-Style Drag-To-Scale Builder)
- Auto-Scaling Font Size Tajuk Program Panjang:
- Dicipta algoritma
getProgramFontSize(components/certificates/types.ts) untuk mengira saiz fon optimum secara pintar berasaskan panjang teks, baris teks, dan kepanjangan baris terpanjang:- Tajuk pendek (<28 aksara, 1 baris): saiz penuh asas (36px - 44px).
- Tajuk sederhana (28-44 aksara): skala ~88% (~32px - 38px).
- Tajuk 2 baris / sederhana panjang (45-79 aksara): skala ~75% (~26px - 32px).
- Tajuk sangat panjang (80+ aksara / 3 baris): skala ~58% (~20px - 24px).
- Dilengkapi
[text-wrap:balance],leading-tight/leading-snug, danmax-w-2xl mx-automerentas kesemua 10 templat sijil pra-bina (Classic,Corporate,Creative,Elegant,Minimalist,Modern,Nature,Premium,Royal,Vintage), templat sijil legasi (components/certificate-template.tsx), dan renderer tersuai (components/certificates/renderer/index.tsx).
- Dicipta algoritma
- Canva-Style Drag-To-Scale & Resize Handles dalam Certificate Builder:
- Menggantikan pemegang tunggal lama dengan sistem pemegang penskalaan Canva penuh (
app/(dashboard)/certificates/builder/[id]/client.tsx):- 4 Pemegang Sudut Bulat (Corner Handles): Top-Left (
nw), Top-Right (ne), Bottom-Left (sw), Bottom-Right (se). - Pemegang Sisi (Pill Side Handles): Middle-Left (
w), Middle-Right (e), Middle-Top (n), Middle-Bottom (s).
- 4 Pemegang Sudut Bulat (Corner Handles): Top-Left (
- Penskalaan Teks: Menarik mana-mana bucu teks/placeholder akan membesarkan/mengecilkan saiz font (
fontSize) dan lebar kotak secara berkadar seiring gerakan tetikus (sama seperti Canva). Menarik pemegang sisi melaraskan lebar balutan teks (text wrap width). - Penskalaan Imej & Bentuk: Menarik bucu menskalakan dimensi dengan mengekalkan nisbah aspek dan berlabuh pada bucu bertentangan; menarik pemegang sisi melaraskan dimensi paksi tunggal.
- Menggantikan pemegang tunggal lama dengan sistem pemegang penskalaan Canva penuh (
- Ujian & Kualiti:
- Ditambah ujian unit baharu di
tests/certificate-typography.test.ts. - 211 / 211 ujian unit lulus merentas 26 suite ujian.
- 0 ralat ESLint, 0 ralat TypeScript, kompilasi Next.js 16 bersih.
- Berjaya dideploy ke pengeluaran Vercel (
https://www.klikform.com, deploymentdpl_3q5feFFHarJGiYZ42n2tqNKzvTaV) dan ditolak ke gitorigin/master(f6db6c3).
- Ditambah ujian unit baharu di
System Improvements (2026-06-05 — Bug Fixes: Account Creation, OAuth Form Creation & Forms Save Trigger)
- Account Creation Database Error: Fixed a critical database error during user signup. The
handle_new_user()trigger function onauth.usersattempted to seed theusagetable using the incorrect column nametotal_forms(should beforms_created) and omitted theNOT NULLcolumnmonth, which caused the database transactions to abort. Created migrationsupabase/migrations/20260605000000_fix_handle_new_user_trigger.sqlto resolve this. - Form Creation Block for OAuth Users: Fixed a bug where users who connected their Google Account via Google OAuth ("Connect with Google") were blocked from creating a form and redirected back to Settings. The check in
createFormActioninactions/forms.tsstrictly demanded manual service account keys (googleClientEmail+googlePrivateKey). Rewrote the validation to allow form creation if either OAuth (googleAccessTokenexists) or Service Account credentials exist. - Forms Save Trigger Error: Fixed a 500 server error when creating/saving a form. The database trigger on the
formstable executed thegenerate_short_codefunction, which had been modified to referenceNEW.slug(for URL shortener links) instead ofNEW.short_code, causing arecord "new" has no field "slug"database abort. Created migrationsupabase/migrations/20260605001000_fix_forms_short_code_trigger.sqlto separate the forms trigger function (generate_form_short_code) from the shortener trigger function.
npm run lint— 0 warnings.npm test— 87/87 pass across 10 suites.npm run build— clean, 43 routes.
- Modified:
actions/forms.ts - Added:
supabase/migrations/20260605000000_fix_handle_new_user_trigger.sql,supabase/migrations/20260605001000_fix_forms_short_code_trigger.sql
- Date: 2026-06-05
- Method: Vercel CLI (
npx vercel --prod --yes) - Production URL:
https://www.klikform.com - Deployment URL:
https://klikform-7hnfgvwlr-sofwan-jailanis-projects.vercel.app
- Vercel Serverless Region Optimization: Ditetapkan region Singapore (
sin1) di dalamvercel.jsonuntuk menghapuskan latensi database (~250ms) dengan pelayan database Supabase. - Halaman Pemasaran Statik (SSG):
- Halaman
/,/pricing,/about,/products/forms,/products/certificates,/products/qr-codes,/products/shortenerditukarkan daripadaƒ (Dynamic)kepada○ (Static). - Mengalihkan logik auth checking ke klien-side di bawah komponen klien baharu
components/landing-header-auth.tsxbagi mengelakkan halaman-halaman pemasaran tersebut tersekat di pelayan. - Membetulkan amaran linter
Unexpected anydanuseEffect react-hooks/exhaustive-depsdi dalamcomponents/pricing/plan-card.tsxdengan menyusun dependencies array [initialUser, plan] dan mengimport jenisUserdari@supabase/supabase-js.
- Halaman
- Pemasangan
@upstash/redis: Memasang pakej kebergantian@upstash/redisdalampackage.jsonuntuk menyokong rate limiting tanpa ralat amaran import dinamik.
npm run lint— 0 warnings.npm test— 87/87 pass across 10 suites.npm run build— clean, 43 routes (semua halaman pemasaran kini static ○).
- Modified:
vercel.json,package.json,app/page.tsx,app/pricing/page.tsx,app/about/page.tsx,app/products/forms/page.tsx,app/products/certificates/page.tsx,app/products/qr-codes/page.tsx,app/products/shortener/page.tsx,components/pricing/plan-card.tsx - Added:
components/landing-header-auth.tsx
- Respondent Confirmation Email dihantar — auto-acknowledgement kepada responden (berasingan daripada notifikasi pemilik yang dikawal
receiveEmailNotifications).- Type baharu
RespondentNotificationSettings { enabled, emailFieldId?, message?, includeSummary? }dilib/types/forms.ts; ditambah keForm+ re-export dilib/types/index.ts. - Migration
supabase/migrations/20260607010000_add_respondent_notification.sql— lajurrespondent_notification jsonbpadaforms+NOTIFY pgrst, 'reload schema'. - Pemetaan storage
lib/storage/forms.ts:respondent_notification↔respondentNotification(2× fromRow getFormById/getFormByShortCode + 1× toRow saveForm). - Template
getRespondentConfirmationEmail(formTitle, message?, summary?)dilib/email/index.ts(tema hijau emerald untuk bezakan daripada edit-link biru). Tambah helperescapeHtml()— semua nilai responden (title, mesej, ringkasan) di-escape untuk halang HTML injection. Ringkasan dicap 12 baris. - Hook fire-and-forget dalam
submitFormAction(actions/forms.ts) selepas blok edit-link. Resolusi emel gunafield.labelsebagai kuncidbData(sama macam edit-link). Ringkasan tapis kunci prefix_(cth_submission_id). - UI builder
components/forms/respondent-notification-card.tsx(cerminEditLinkCard): toggle, pemilih medan emel, textarea mesej tersuai (1000 char), toggle sertakan ringkasan. Mount diapp/builder/[id]/client.tsxselepasEditLinkCard. - Tests
tests/respondent-notification.test.ts— 7 tests (subjek, mesej lalai vs tersuai, ringkasan on/off, HTML escaping anti-injection, cap 12 baris).
- Type baharu
- Nota teknikal:
getNewSubmissionEmail(notifikasi pemilik) masih TIDAK escape input pengguna — potensi HTML injection dalam emel pemilik. Belum dibaiki (luar skop pass ini); calon pembaikan keselamatan berasingan.
npm run lint— 0 warnings.npm test— 94/94 pass across 11 suites (was 87/87).npm run build— clean, 43 routes.
- Added:
components/forms/respondent-notification-card.tsx,supabase/migrations/20260607010000_add_respondent_notification.sql,tests/respondent-notification.test.ts - Modified:
lib/types/forms.ts,lib/types/index.ts,lib/storage/forms.ts,lib/email/index.ts,actions/forms.ts,app/builder/[id]/client.tsx
Empat track dihantar dalam satu pass. Lint 0, 121/121 tests (14 suites), build clean 44 routes.
lib/email/index.ts:escapeHtml()(function declaration, hoisted) kini diguna merentasgetNewSubmissionEmail(userName, formTitle, submissionData key+value, googleSheetUrl href),getEditLinkEmail(formTitle ×2), dangetRespondentConfirmationEmail. Tutup vektor HTML/markup injection daripada nilai responden dalam emel pemilik (isu yang dibangkitkan dalam pass sebelum).- Tests ditambah ke
tests/respondent-notification.test.ts(kini 9): escaping data submission + nama/title pemilik.
- Type
PdpaSettings { enabled, consentText?, policyUrl? }dilib/types/forms.ts+Form.pdpaSettings+ barrel. - Migration
supabase/migrations/20260607020000_add_pdpa_settings.sql— lajurpdpa_settings jsonb+ NOTIFY pgrst. - Storage
lib/storage/forms.ts:pdpa_settings↔pdpaSettings(2× fromRow + toRow). - Helper tulen
lib/forms/pdpa.ts:requiresPdpaConsent,isConsentGiven(hanya string'true'),isPdpaSubmissionAllowed. - Public form
app/(public)/form/[id]/client.tsx: checkbox consent (statepdpaConsent, hanya bila bukan editMode), blockhandleSubmit+ disable butang jika tak tick, append_pdpa_consent='true'. ServersubmitFormActionkuatkuasa (tolak jika enabled tapi consent ≠ true) — tak boleh bypass via scripting. Consent direkod sebagai lajurPersetujuan PDPA: Ya/Tidakdalam dbData (drop raw_pdpa_consent). - UI builder
components/forms/pdpa-card.tsx+ mount selepas RespondentNotificationCard. - Tests
tests/pdpa.test.ts— 8.
- Migration
supabase/migrations/20260607030000_add_audit_logs.sql— jadualaudit_logs(user_id FK auth.users ON DELETE CASCADE, action, entity_type, entity_id, metadata jsonb, created_at), index(user_id, created_at DESC), RLS owner-only SELECT sahaja (TIADA polisi INSERT — immutable dari klien; tulis via service role), fungsiprune_audit_logs()(SECURITY DEFINER, search_path='', 365-hari retention). - Type
lib/types/audit.ts(AuditAction,AuditLog) + barrel. Nota: jangan padam eksportTIER_LIMITSbila edit barrel (hampir tersilap). - Storage
lib/storage/audit.ts(import 'server-only'):logAudit()resolve user dari auth lalu insert via admin client (fire-and-forget, swallow error);listAuditLogs()RLS-gated. - Formatter tulen
lib/audit/format.ts:describeAuditAction(label Melayu),describeAuditLog(gabung dengan metadata.title/name),auditActionKind(create/delete/update/other). - Hook
logAuditdalamcreateFormAction(selepas incrementFormCount, sebelum redirect) +deleteFormAction(fetch title dulu, lepas delete, sebelum redirect). updateFormAction TIDAK di-log (autosave terlalu bising). - Dashboard
app/(dashboard)/audit/page.tsx(force-dynamic) + pautan "Log Audit" (ikon ScrollText) dicomponents/dashboard/sidebar.tsx+/auditditambah ke protectedRoutesproxy.ts. - Tests
tests/audit-format.test.ts— 7.
- Jenis medan baharu
pagebreakdiFormFieldType(pemisah; tiada migration — guna array sedia ada, backward-compatible). - Helper tulen
lib/forms/pagination.ts:splitIntoPages(split di pagebreak, buang marker, sentiasa ≥1 page),isMultiPage,findAdjacentNonEmptyPage(skip page kosong akibat conditional),lastNonEmptyPageIndex. - Public form: state
currentPage;visiblePages= splitIntoPages.map(filter visible); rendercurrentPageFields; butang Kembali/Seterusnya/Submit + "Halaman X / Y" (kira page non-kosong sahaja); validasi per-page pada Next; PDPA consent + Submit hanya di page akhir; guard Enter (multiPage && !isLastPage → goNext). Penting:visibleFieldskini kecualikanpagebreaksupaya tidak divalidasi/dihantar/dikira (elak lajur "Page Break" dalam Sheet). - Builder
components/forms/fields-editor/index.tsx: SelectItem "Page Break (Multi-page)", butang "Add Page Break", kecualikan pagebreak dari sumber syarat + sembunyi toggle required & conditional editor. - Tests
tests/pagination.test.ts— 10.
npm run lint— 0 warnings.npm test— 121/121 pass across 14 suites.npm run build— clean, 44 routes (+/audit).
20260607010000_add_respondent_notification.sql20260607020000_add_pdpa_settings.sql20260607030000_add_audit_logs.sql
- Added:
lib/forms/pdpa.ts,lib/forms/pagination.ts,lib/audit/format.ts,lib/storage/audit.ts,lib/types/audit.ts,components/forms/pdpa-card.tsx,app/(dashboard)/audit/page.tsx,supabase/migrations/20260607020000_add_pdpa_settings.sql,supabase/migrations/20260607030000_add_audit_logs.sql,tests/pdpa.test.ts,tests/audit-format.test.ts,tests/pagination.test.ts - Modified:
lib/email/index.ts,lib/types/forms.ts,lib/types/index.ts,lib/storage/forms.ts,actions/forms.ts,app/(public)/form/[id]/client.tsx,components/forms/fields-editor/index.tsx,components/dashboard/sidebar.tsx,app/builder/[id]/client.tsx,proxy.ts,tests/respondent-notification.test.ts
Fasa B — STATUS: SIAP (4/4 feature: notifikasi responden, PDPA, audit log, multi-page forms). Fasa C masih pending.
- Prasyarat: 3 migration (respondent_notification, pdpa_settings, audit_logs) diapply ke DB produksi DAHULU (disahkan oleh pengguna "db dh settel") sebelum deploy — kerana
saveFormupsert lajur baharu; deploy sebelum migration akan pecahkan simpan borang (PGRST204). - Method: Vercel CLI (
npx vercel --prod --yes). - Production URL:
https://www.klikform.com - Deployment URL:
https://klikform-4turtui9x-sofwan-jailanis-projects.vercel.app - Status: Build completed (~1m), Ready in ~2m, aliased ke www.klikform.com. Lint 0, 121/121 tests, build clean 44 routes.
- Builder convention is English (e.g. "E-Cert Settings", "Attendance & Location"). The Fasa A/B cards I added were in Malay, breaking consistency. Standardized all owner-facing UI to English:
components/forms/webhooks-card.tsx— descriptions, buttons (Add/Generate/Cancel/Add Webhook), toasts, confirms, aria-labels, empty state, localeen-MY.components/forms/edit-link-card.tsx— "Response Edit Link" + all labels/placeholders/help text.components/forms/respondent-notification-card.tsx— "Respondent Confirmation Email" + all strings.components/forms/pdpa-card.tsx— "PDPA Consent" + labels;DEFAULT_CONSENTnow English.components/forms/fields-editor/index.tsx— "Add Page Break" title tooltip.app/(dashboard)/audit/page.tsx— "Audit Log", "Recent Activity", empty state.lib/audit/format.ts—ACTION_LABELSnow English ("Form created", etc.);tests/audit-format.test.tsupdated to match.components/dashboard/sidebar.tsx— nav item "Audit Log".app/(public)/form/[id]/client.tsx— PDPA consent default text + "Privacy Policy" link + consent toast + page nav buttons "Back"/"Next"/"Page X / Y".actions/forms.ts— PDPA Sheet column renamedPersetujuan PDPA→PDPA Consent, valueYa/Tidak→Yes/No.
- Deliberately kept Malay: the email layer (
lib/email/index.ts—getRespondentConfirmationEmailand ALL existing templates are Malay; translating only the new one would CREATE inconsistency) and the pre-existing public respondent form chrome ("Terjawab" badge, "Borang Ditutup", etc.). Only my newly-added public strings were aligned to English. - Build state: lint 0, 121/121 tests, build clean 44 routes.
- Method: Vercel CLI (
npx vercel --prod --yes). No schema change (UI/string-only), safe deploy. - Production URL:
https://www.klikform.com - Deployment URL:
https://klikform-k6mxw0e9h-sofwan-jailanis-projects.vercel.app - Status: Build ~2m, Ready, aliased to www.klikform.com.
- Symptom: Chrome console
[Violation] Forced reflow while executing JavaScript took 33ms. - Root cause:
app/(dashboard)/certificates/builder/[id]/client.tsxreadcanvasRef.current.offsetWidthinsidetemplate.elements.map(...)→ one layout read per element per render → layout thrashing during drag/resize. - Fix: Added
canvasWidthstate fed by aResizeObserveron the canvas;scalenow computed once per render (wrapped the element map in an IIFE) fromcanvasWidthinstead of reading the DOM. Also fixes a latent bug where font preview didn't rescale on window resize. getBoundingClientRectinhandleMouseMoveleft as-is (one read per event, not per render).- Build state: lint 0, 121/121 tests, build clean.
- Context: "Google hasn't verified this app" warning on "Connect with Google" — caused by requesting sensitive scopes without completing Google OAuth verification (process matter, not a code bug). Resolved via: keep app in Testing + add test users, OR submit for verification (privacy/terms pages already exist).
- OAuth scopes (
lib/api/google-auth.ts):drive.file+spreadsheets+userinfo.email— all "sensitive" tier (NOT restricted; no annual security assessment needed). Already minimal for current features. Added justification comments to aid verification submission. - Least-privilege fix:
lib/api/google-sheets.tsservice-account JWTs inappendToSheet+updateSheetRowpreviously requested fulldrive(RESTRICTED scope) +spreadsheets, but those functions only use the Sheets API (loadInfo/addRow/getRows/save) — never Drive. Narrowed to['spreadsheets']only.createSpreadsheetkeepsdrive(genuinely uses Drive API: files.create, about.get quota, permissions). Service-account scope narrowing is immediate (no re-consent) and doesn't affect the OAuth consent screen. - Open product decision (NOT applied): OAuth could drop
spreadsheetsand rely ondrive.filealone IF the product only supports app-created Sheets (drive.file covers Sheets API for app-created files). Tradeoff: OAuth users could no longer connect a pre-existing sheet they made manually. Would reduce OAuth to a single sensitive scope (easiest verification). Left to user. - Build state: lint 0, build clean. (Service-account scope change is scope-narrowing — provably correct since those paths call Sheets API only — but not runtime-tested against live Google here.)
- Problem found: git was stuck at Fasa A commit (
aa4dba7); ALL Fasa B work + bug fixes + SSG load-speed + i18n + perf + scope changes were uncommitted (deployed viavercel --prodfrom working dir, so production was ahead of git — no history/rollback). - Resolved: removed stale
build_full.log/build_output.log, added*.logto.gitignore. Created branchfeat/fasa-b-improvementsand committed everything in 3 logical commits:c03b04cchore: ignore *.log + remove stale logs8c0ef04perf: SSG marketing pages + sin1 region + signup/forms trigger fixes (the earlier uncommitted production work)c297cf6feat: Fasa B + email escaping + English UI + perf (ResizeObserver) + least-privilege scope- Pushed to
origin/feat/fasa-b-improvements. Working tree clean.
- Deploy:
npx vercel --prod→https://www.klikform.com(deploymentklikform-69xm4jcm7). Now production includes the perf + scope fixes too. - Security advisories (monitored, not fixed):
vitest <4.1.0critical but dev-only (UI server unused; we runvitest run) — bumping is breaking, low value.postcss <8.5.10moderate via Next — awaiting upstream. - Still open (larger, not started): Fasa C (custom domain, workspaces, payments-in-form, templates gallery, AI generator, backup); integration tests for storage/action paths; a11y audit (axe-core in deps); consider git-based Vercel auto-deploy for traceability.
- Vercel project already connected to GitHub repo
sofwanwork/wanztech(verified viavercel git connect→ "already connected"). Production branch =master. - Root cause of "git out of sync":
masterwas behind since Fasa A — everything was deployed viavercel --prodfrom the working dir, never merged tomaster. Fixed by fast-forwardingmasterto the feature branch and pushing → from now on, push tomasterauto-deploys (no more manual CLI needed).
tests/audit-storage.test.ts(8): logAudit insert shape, default null/{}, skip when no user, swallow errors; listAuditLogs no-user→[], row mapping, limit clamp (1..200), error→[].tests/edit-token-storage.test.ts(9): createEditToken 64-hex + expiry math + insert shape + throw on error; getEditToken not_found/used/expired/valid mapping + short-token guard; markEditTokenUsed update scope + never-throws.tests/webhook-storage.test.ts(5): listWebhooksForForm masks secret (never decrypts); listWebhooksForDispatch decrypts; createWebhook ownership rejection + encrypt-on-write + masked return.- Pattern:
vi.hoisted+vi.mockfor@/utils/supabase/server|adminand@/lib/encryption; chainable builder mock (methods return builder;.single()+ thenable resolve configurable results). Total tests now 143 (was 121).
app/(public)/form/[id]/client.tsx: associated each field<Label>with its control (htmlFor/id=field-input-${id}) for text/email/number/textarea/select; addedid=field-label-${id}+aria-labelledbyon select trigger androle="group"+aria-labelledbyon checkbox & radio groups. Required marker:aria-hiddenon the*+sr-only"(wajib)" text. Fixes WCAG 1.3.1 / 4.1.2 (label-control association).- Note: full WCAG conformance still needs manual testing with assistive tech; this pass fixed the clear programmatic gaps. Automated axe testing would need jsdom + vitest-axe setup (not added to avoid dep bloat).
- lint 0, 143/143 tests (17 suites), build clean.
- Not done (per user): Fasa C.
- Bug found & fixed: no
instrumentation.tsat root → in Next.js 16 + @sentry/nextjs v10,sentry.server.config.ts/sentry.edge.config.tswere NEVER loaded, so server/edge errors went uncaptured even with a DSN set. Created:instrumentation.ts—register()imports server/edge config perNEXT_RUNTIME; exportsonRequestError = Sentry.captureRequestError.instrumentation-client.ts— imports./sentry.client.config(single init) + exportsonRouterTransitionStart.- All still no-op without
NEXT_PUBLIC_SENTRY_DSN. Build verified clean.
- To activate Sentry (user action — secrets): set in Vercel env →
NEXT_PUBLIC_SENTRY_DSN,SENTRY_ORG,SENTRY_PROJECT,SENTRY_AUTH_TOKEN. next.config.ts only wraps withSentryConfig when DSN + AUTH_TOKEN present. Prod trace sampling already 10%. SMOKE_TEST.mdcreated (user-requested): step-by-step manual checklist for prod flows that automated tests can't cover (Google connect, form submit → Sheet + emails, edit link, certificates, bulk, audit, analytics, payment, Sentry).- Honest status given to user: lint/test/build green, but NOT provably bug-free — external-service flows (OAuth, live Sheets/Drive, Resend, BCL, cert render) are not runtime-tested; service-account scope narrowing not live-tested.
NEXT_PUBLIC_SENTRY_DSNset in Vercel Production (DSN region:.de/EU) + added to local.env.local. (NEXT_PUBLIC bakes at build → required a redeploy to take effect.)- Verified working: temporary
/sentry-test?throw=1route triggered a server error that appeared in Sentry Issues ("KlikForm Sentry test error (server)") — confirms theinstrumentation.tsserver-capture fix works end-to-end. Test route then removed (commitf73e141). - Still optional for readable stack traces (source maps): set
SENTRY_ORG,SENTRY_PROJECT,SENTRY_AUTH_TOKENin Vercel — only then doesnext.config.tswrap withwithSentryConfigto upload source maps. Currently DSN-only = capture works but traces are minified. - Git:
masteratf73e141, working tree clean, auto-deploy on push confirmed working.
- Set in Vercel Production:
SENTRY_ORG=wanz-tech-enterprise-v4,SENTRY_PROJECT=javascript-nextjs,SENTRY_AUTH_TOKEN(org auth token, EU region). Now all 4 Sentry vars present →next.config.tswraps withwithSentryConfigand uploads source maps on build. - Fresh prod build deployed (
klikform-5wvxvllif, aliased www.klikform.com). Production errors now report with readable stack traces. - Security note: auth token was shared in chat — user may rotate it in Sentry (Settings → Auth Tokens) if concerned.
- Console error: "Creating a worker from 'blob:' violates CSP script-src ... worker-src not set". Sentry Replay creates a blob: Web Worker for compression; CSP lacked
worker-srcso it fell back toscript-src(no blob:) and was blocked → Replay broken (error capture itself was fine). - Fix: added
worker-src 'self' blob:to the CSP innext.config.ts. Commit3a97b58, pushed to master (auto-deploy). - The repeated
monitoring?o=...&r=de ERR_BLOCKED_BY_CLIENT= the developer's own browser ad/privacy blocker blocking the/monitoringSentry tunnel route. Environmental, not a code bug; real users without blockers are unaffected. Could renametunnelRouteto evade blocklists if it becomes a problem.
- User report: "press Next → auto-submits." Root cause: the Next button (
type="button") can't submit — but a page break placed at the END of the form (the "Add Page Break" button appends to the bottom) created an empty trailing page, so the form showed Submit (not Next) on the content page, and pressing it submitted. - Fix (public form
app/(public)/form/[id]/client.tsx): simplified pagination —pages = splitIntoPages(...).map(filter visible).filter(non-empty).multiPage = pages.length > 1. Empty pages (leading/trailing pagebreaks or all-conditional-hidden) are now DROPPED entirely, so a stray pagebreak never creates a phantom page or premature Submit. Navigation is simple index ±1 overpages;isFirstPage/isLastPage/displayCurrent/displayTotalderived from it. Removed use offindAdjacentNonEmptyPage/lastNonEmptyPageIndex/isMultiPagein the component (helpers still exported + unit-tested). - Fix (builder
components/forms/fields-editor/index.tsx): page break now early-returns a distinct divider UI ("── PAGE BREAK ──" + hint "Fields below this line start a new page") with drag handle + delete — no more confusing label/description/type inputs (this is why the user saw "no description"). Also removed the now-redundantfield.type !== 'pagebreak'guards on the required toggle + conditional editor (TS narrowed the type after the early return → would've been a build type error; caught & fixed). - Build state: lint 0, 143/143 tests, build clean (exit 0, type-check passes).
_submission_idANDtimestampare now only written to the Google Sheet when Edit Link (magic link) is enabled on the form. Forms without it get a clean sheet (just the respondent's answers).- Implemented server-side in
submitFormAction(actions/forms.ts):const editLinkEnabled = !!form.editLinkSettings?.enabled;→ if enabled setdbData._submission_id = submissionId, elsedelete dbData._submission_id+delete dbData.timestamp. Done before the Sheet write so the edit-link token flow still has_submission_idavailable. - Rationale: both columns are bookkeeping for the edit feature (locate the exact row to update). Useless clutter for simple forms.
- Note: existing forms that previously wrote these columns will leave them blank on NEW rows once Edit Link is off (historical rows keep their values). Commit
83217f6, pushed → auto-deploy. lint 0, build clean.
- Feature: a form can map a dropdown (
select) field's answer → a different certificate template (e.g. Urusetia/Penganjur/Peserta each get a different cert). Falls back to the default template when a respondent's value has no mapping. - Type
CertificateCategoryConfig { fieldId, map: Record<option,templateId> }onForm.eCertificateCategory(+ barrel). Migration20260607040000_add_certificate_category.sqladdse_certificate_category jsonb. Storage mapping inlib/storage/forms.ts(2× fromRow + toRow). - Pure helpers
lib/certificates/category.ts:resolveCategoryTemplateId(config, value, default)+collectTemplateIds(config, default). Teststests/certificate-category.test.ts(9). Total 152. actions/certificates.ts:CertificateCheckResult.categoryadded;checkCertificateByICOrEmailreads the category column (Sheet column keyed by the field's LABEL, case-insensitive) from the matched row.getFormForCertificateCheckreturnseCertificateCategory.check/[formId]/page.tsx: prefetches default + all mapped templates (collectTemplateIds) intotemplatesById, passescategoryConfig+templatesByIdto client.check/[formId]/client.tsx: computesactiveTemplateviaresolveCategoryTemplateIdafter lookup; both preview + hidden-capture renders useactiveTemplateinstead of the singlecustomTemplateData.- Builder UI
components/forms/certificate-category-card.tsx(CertificateCategorySection): toggle (needs aselectfield), pick category field, per-option template dropdown ("Use default template" = fallback). Mounted in the E-Cert card after the template grid (only when a default template is chosen). UsesuserCertificatesprop already available in builder. - PENDING: apply migration
20260607040000_add_certificate_category.sqlto prod DB before this works (saveForm upsertse_certificate_category). lint 0, 152/152 tests, build clean. Commit49f0599, pushed.
- Feature: visualize the actual Google Sheet answers as charts (like Google Forms' Responses tab) — distribution per choice/rating question.
- Pure helper
lib/analytics/responses.ts:aggregateResponses(rows, fields)+isChartable(). Chartsselect/radio/checkbox/rating. Checkbox answers split on commas; rating gets an average. Seeds declared options so 0-count options show; sorts by count desc; pct relative to respondents who answered. Teststests/response-summary.test.ts(6). Total 158. lib/api/google-sheets.ts: addedreadSheetRows(config)— read-only, returns rows as objects keyed by header. Service-account usesspreadsheets.readonly.actions/response-summary.ts:getFormResponseSummary(formId)— owner-gated (auth +form.userId === user.id), resolves OAuth (with token refresh) or service account, reads sheet, aggregates. Returns{ ok, summaries, totalResponses }.- UI
app/(dashboard)/responses/[id]/analytics/response-charts.tsx(ResponseChartsSection): on-demand "Generate charts" button (avoids slow page load on big sheets), CSS bar charts per question, refresh button, empty/no-data states. Mounted on the analytics page under the events analytics. - Also fixed pluralization: "1 focus" / "N focuses" in
analytics/client.tsx. - lint 0, 158/158 tests, build clean. Commit
2db33cf, pushed.
- Added
'ic'toPlaceholderType(lib/types/certificates.ts).CertificateData.ic+ rendererdata.icalready existed, soresolveContentrenders it automatically. - Builder sidebar (
components/certificates/builder/sidebar.tsx): new "No. Kad Pengenalan" placeholder button (Fingerprint icon) → inserts placeholder withplaceholderType: 'ic'. PLACEHOLDER_LABELSin cert builder client gainsic: '{No. KP}'(canvas preview label).- Value source: the check flow passes
ic={identifier}(what the visitor searched with). So when searched by IC → shows IC. Nuance: if the visitor searched by EMAIL, the IC placeholder would show the email (identifier), not the real IC — would need to read the IC column from the sheet row to fix. Not done yet (basic placeholder shipped as requested). Bulk flow maps the CSViccolumn already. - lint 0, build clean. Commit
3ef6e62, pushed.
- Pure helper
lib/certificates/serial.ts:generateCertSerial(formId, identifier)→SIJIL-XXXXXXXX(FNV-1a 32-bit hash → 8 hex). Deterministic (same form+person → same code; different form/person → different); returns '' when no identifier (avoids a shared code). Teststests/certificate-serial.test.ts(6). Total 164. 'serial'added toPlaceholderType;CertificateData.serial+ renderer data type already wired viaresolveContent(data[placeholderType]).components/certificate-template.tsx: computesserial: generateCertSerial(formId, ic)in the customTemplateData branch. (Note: serial uses the search identifier — IC if searched by IC; the same email-search nuance as the IC placeholder applies.)- Bulk:
bulk/client.tsxpassesserial: generateCertSerial(template.id, ic)(no formId in bulk context, so keyed by template id). - Builder sidebar: new "Nombor Siri" placeholder button (Hash icon) →
placeholderType: 'serial'.PLACEHOLDER_LABELS.serial = '{No. Siri}'. - lint 0, 164/164 tests, build clean. Commit
1afffb9, pushed. Outstanding migration pending:20260607040000_add_certificate_category.sql— DONE (applied to production, confirmed 2026-06-11).
app/builder/[id]/client.tsx(~line 992): added a "Default" badge (top-right, primary pill +CheckCircle2icon) on the selected cert template card in the "Select Certificate Template" gallery. Shows only whenform.eCertificateTemplate === cert.id. Makes the default-template selection explicit beyond the existing blue border/ring.CheckCircle2already imported from lucide-react. tsc --noEmit clean.
- Root cause:
check/[formId]/client.tsxpassedic={identifier}(raw search input). When a visitor searched by EMAIL, the IC placeholder + serial used the email, not the real IC. actions/certificates.ts: addedic?toCertificateCheckResult; extractedisIcHeader()helper (reused for IC search + email-search IC lookup). On match, resolveicColumnIndexregardless of search method and return the real IC from the sheet row.check/[formId]/client.tsx: both<CertificateTemplate>instances (preview + hidden capture) now useic={result.ic || identifier}— real IC when available, falls back to identifier.- Serial (
generateCertSerial(formId, ic)) is now stable per person regardless of search method. tsc clean, 164/164 tests pass.
app/builder/[id]/client.tsx:- "Default" badge now has a native
titletooltip explaining it applies to participants without a specific category. - Added
handleSelectTemplate(templateId)+applyTemplate(templateId)helpers. Clicking a cert card now goes throughhandleSelectTemplate: ifform.eCertificateCategory?.maphas any entries (category mappings exist), it opens an AlertDialog confirmation before changing the default; otherwise applies directly. No-op if same id. - New state
pendingTemplateId. AlertDialog (controlled) imported from@/components/ui/alert-dialog, rendered after</main>. Copy clarifies existing category mappings stay intact.
- "Default" badge now has a native
- No new deps (used native title + existing radix alert-dialog). lint 0, tsc clean, 164/164 tests.
- Added
backgroundImage?: stringtoFormTheme(lib/types/forms.ts, afterbackgroundPattern). Stored in the existingthemejsonb column — no DB migration needed (saveForm already persists whole theme; all read paths map theme back). - Public render
app/(public)/form/[id]/client.tsx: destructuredbackgroundImagefrom theme; addedgetBackgroundStyle()helper that layers color + optional pattern + optional photo. Photo usesgetProxiedImageUrl(Google Drive support),background-size: cover,background-attachment: fixed. When a pattern is also set, pattern gradient overlays the photo (repeat, no-repeat). Wrapper div now usesgetBackgroundStyle()instead of inline color+pattern. - Builder
app/builder/[id]/client.tsx: added "Background Image URL" field + live preview + Remove button in the Theme Settings card (after Background Pattern), matching the cover-image URL-input pattern. - URL-input approach (no uploader) — consistent with cover image & logo. Verified: tsc clean, lint 0, 164/164 tests, production build clean.
- Bug:
app/(public)/check/[formId]/client.tsxhardcoded landscape1123x794in 3 places — preview box, Tailwindscale-[...]breakpoints, and the hidden capture container. Portrait certs (height > width) rendered distorted, and PNG/PDF download used wrong orientation (the old DOM-offsetisPortraitcheck always read landscape because dims were forced). - Root: the
CertificateRendereritself is orientation-agnostic (percent-based,100%), and the cert builder has a portrait/landscape toggle (toolbar.tsx). Only the check page assumed landscape. - Fix: derive orientation from
activeTemplate.width/height→isPortrait,captureWidth/captureHeight. Replaced fragilescale-[...]breakpoints with a measured scale:previewWrapperRef+ResizeObservercomputespreviewScale = availableWidth / captureWidth; wrapper height =captureHeight * previewScale. Hidden capture container + both download handlers now usecaptureWidth/Height; jsPDF uses the component-levelisPortrait. - Verified: tsc clean, lint 0, 164/164 tests, production build clean.
- After the orientation fix, portrait certs still overflowed because
previewScalewas width-only (availableWidth / captureWidth), making tall portrait certs taller than the card. - Fix: scale now "contains" within BOTH width and a capped max height:
maxHeight = min(520, innerHeight*0.6),previewScale = min(available/captureWidth, maxHeight/captureHeight). Added window resize listener (in addition to ResizeObserver) andcaptureHeightto the effect deps. - Verified: tsc clean, lint 0, 164/164 tests, production build clean.
app/builder/[id]/client.tsx(~line 570): Google Sheet Share URL<Input>is nowreadOnlyby default (muted bg) to prevent accidental edits/deletion that break Sheet access. Wrapped in a flex row with two icon buttons: Copy (uses existingcopyToClipboard(form.googleSheetUrl), disabled when empty) and an Edit/Lock toggle (sheetUrlEditablestate — Pencil when locked, Lock when editable). AddedPencil+Locklucide imports and a helper caption.- Verified: tsc clean, lint 0, 164/164 tests, production build clean.
- Symptom: respondent opens magic edit link, edits, submits → "Google API error - [401] Request had invalid authentication credentials. Expected OAuth 2 access token...".
- Root cause:
actions/edit-response.tspassed the storedsettings.googleAccessTokenstraight toupdateSheetRowwith NO refresh. Google OAuth access tokens live ~1h; a magic link is opened later, so the token is dead.updateSheetRow/appendToSheetcheckif (config.accessToken)FIRST, so a stale token short-circuits the service-account fallback → 401. The normalsubmitFormActionworks only because it refreshes the token first. - Fix: added shared helper
getValidAccessToken({accessToken, refreshToken, tokenExpiry, userId})inlib/api/google-auth.ts— refreshes when expiring within 5 min and persists the new token to the owner's settings row (dynamic imports of admin client + encrypt to keep it server-safe).edit-response.tsnow calls it beforeupdateSheetRow. - Deliberately did NOT refactor the working
submitFormAction/cert-check inline refresh blocks (avoid regressions on the critical submit path); the helper exists for future consolidation. - Verified: tsc clean, lint 0, 164/164 tests, production build clean.
- Symptom: user submits form, no email when magic link (edit link) is ON. Submission still succeeds.
- Two likely root causes addressed (couldn't confirm which fired in prod without logs):
- (C) Silent missing
emailFieldId: builder let owner toggle ON without picking an email field (Select defaulted to ''), so server guardeditCfg.enabled && editCfg.emailFieldIdshort-circuited with no send/warning. - (A)
createEditTokenthrows (migration/RLS/service-key) → jumped tocatchthat onlyconsole.warn'd; email never attempted.
- (C) Silent missing
- Fixes:
actions/forms.ts(~514): guard noweditCfg?.enabledonly; resolves email field asfind(id === emailFieldId) || find(type === 'email')(fallback to first email field). Added explicit warns for no-field / invalid-email, surfacedsendEmailfailure viaconsole.error, and upgraded the catch toconsole.error.components/forms/edit-link-card.tsx: newhandleToggleauto-selects the first email field when enabling if none chosen.
- Note for prod: check server logs for
[edit-token] create error:to confirm whether theresponse_edit_tokensmigration (20260529040000_add_response_edit_tokens.sql) +SUPABASE_SERVICE_ROLE_KEYare correctly applied in the failing env. If token creation is the failure, the email still won't send (the link needs the token) — that's a config/migration issue, not code. - Verified: tsc clean, lint 0, 164/164 tests, production build clean.
- User wanted a premium, minimalist, single-theme-color look (no more per-email colored gradient headers).
- Rewrote
lib/email/index.tsdesign system: ONE accent (BRAND #4f46e5indigo) + neutral ink/whitespace on a light#f4f4f5bg. White card, 1px hairline border, soft shadow, 4px indigo top accent strip, plain wordmark header (no big emoji headers). Shared helpers:eyebrow()(uppercase accent label),heading(),para(),button()(bulletproof single-color CTA),note()(accent-tinted box),caption(),kvRow()/kvTable(),bulletList(items, 'check'|'dot'),cardBody(). All 10 templates rebuilt on these. - Subjects keep a single leading emoji for inbox scannability (body stays emoji-free). Confirmation subject MUST start with ✅ (test spec).
- Test-driven constraints rediscovered:
getRespondentConfirmationEmailsubject must match/^✅/AND the escapedformTitlemust appear in the HTML (tests/respondent-notification.test.ts). Fix: kept ✅ in subject + renderformTitle(escaped) as a "Borang: …" line in the body. Also the custom-message test forbids the default phrase leaking — preheader usescustomMessage || default. - Updated the 3 Supabase auth templates (
supabase/templates/*.html) to match the new light minimalist design (confirm-signup, reset-password, magic-link). Same indigo accent, must be pasted into Supabase dashboard. - Removed unused
divider()helper (lint). Verified: tsc clean, lint 0, 164/164 tests, production build clean. - (History) An earlier pass added preheaders + a darker gradient wrapper; that has been SUPERSEDED by this minimalist single-color redesign.
supabase/templates/README.mdexists with paste instructions;{{ .ConfirmationURL }}is the Supabase variable used.
- Feature: Collapsed the advanced "Validation Rules" and "Conditional Logic" settings inside each question card in the Form Builder by default to clean up the interface for non-technical users.
- Accordion Integration: Wrapped both sections in a multi-expandable
<Accordion type="multiple">from@/components/ui/accordion. - Active Badges: Added dynamic visual badges (
"active"for validation;"{N} rule(s)"for conditional logic) to the accordion triggers, allowing builders to see at a glance if a question has active rules/conditions without expanding it. - Clean Layout: Removed duplicate headings and dividers from both sections, optimizing space when expanded.
- Verified:
npm run lintclean (0 warnings),npm testclean (164/164 tests passing), and production buildnpm run buildclean.
- Feature: Added a pre-made "IC (MY)" regex validation pattern preset (
^[0-9]{6}-[0-9]{2}-[0-9]{4}$|^[0-9]{12}$) incomponents/forms/fields-editor/index.tsx. - This enables form creators to easily enforce Malaysian IC format (either 12 digits or with dashes) without writing custom regular expressions.
- Verification: Verified using
npm test(all 164 tests passed).
- Feature: Added automatic dash-formatting (
XXXXXX-XX-XXXX) to fields labeled "IC", "No IC", "No. IC", "Kad Pengenalan", or "NRIC" (or fields using the IC regex pattern preset) in the public form client (app/(public)/form/[id]/client.tsx). - Prefilled/initial values and live user typing are both formatted automatically.
- Verification: Verified using
npm test(all 164 tests passed).
- Feature: Automatically formats the
{No. KP}(IC placeholder) value to include dashes (XXXXXX-XX-XXXX) in the certificate generator renderer (components/certificates/renderer/index.tsx). This ensures that generated certificates and public check page previews always display the IC with dashes even if stored without them in the source sheet. - Mock Data: Updated the mock preview value in the builder preview page (
app/(dashboard)/certificates/builder/[id]/preview/page.tsx) to901234-56-7890. - Verification: Verified via local compilation (
npm run build) and test suite runs (npm test).
- Fix: Updated
resolveCategoryTemplateIdinlib/certificates/category.tsto look up category mappings case-insensitively. This fixes the issue where sheet values like "peserta" or "urusetia" (lowercase or varying casing) failed to match mappings in the builder (such as "Peserta" or "Urusetia"). - Verification: Added unit test in
tests/certificate-category.test.tsverifying case-insensitive resolution. All 165 tests passed, and local build was verified clean.
- Bug: When searching a certificate by email, if the IC column in the Google Sheet wasn't detected, the check page fell back to the search input (
identifier), printing the respondent's email address on the certificate's{No. KP}placeholder. - Fix (Utility): Created headers.ts with
isIcHeader(h: string). It extends matching to common Malaysian abbreviations (kp,no kp,no. kp,no.kp,nombor kp) and uses word-boundary regexes (/\bkp\b/,/\bic\b/) to match compound headers (e.g.IC/Passport,No. KP/Passport) securely without false positives on other fields likeofficeortimestamp. - Fix (Frontend): Updated client.tsx to prevent email addresses from being passed as
ic. The template now usesic={result.ic || (identifier.includes('@') ? '' : identifier)}. - Verification: Created certificate-headers.test.ts covering matches, boundary cases, and negative cases. All 171 tests passed, and
tsc --noEmitcompiles cleanly.
- Feature: Added a redirect button configuration that appears on the Thank You page after successful form submission, supporting multiple redirect links.
- Database Schema: Added nullable
redirect_buttonsJSONB column to theformstable in Supabase. Created migrationsupabase/migrations/20260619144800_add_redirect_settings.sqlwhich drops the single-link columns and introduces the JSONB column. - Builder UI: Added a settings card "Custom Thank You Buttons" under the custom thank you message in
app/builder/[id]/client.tsxthat manages an array of button records (label and URL) with list controls. - Public Form Submitted View: Loops over and displays configured redirect buttons in the card footer of the success page.
- Formatting: Implemented
formatRedirectUrlhelper inapp/(public)/form/[id]/client.tsxto sanitize custom redirect URLs. - Verification: Verified using
npm test(all 171 tests passed) andnpm run build(compiled successfully).
- Feature: Standardized the form opening/closing time restrictions (Attendance & Location feature) to evaluate against the Malaysia Timezone (UTC+8 / Asia/Kuala_Lumpur) rather than the local device timezone of the respondent.
- Implementation:
- Added
parseMalaysiaTimeandformatInMalaysiaTimehelper functions in client.tsx. - Modified the time checks (startTime and endTime) to parse dates with a forced UTC+8 offset if no timezone offset is specified.
- Custom-formatted the access-denied date strings in the Malaysia Time timezone.
- Rewrote the Countdown Timer target date computation to utilize the forced UTC+8 offset.
- Added
- Verification: Verified using
npm test(all 171 tests passed) andnpm run build(compiled successfully).
Sembilan pembetulan risiko/kualiti dari audit penuh sistem. Semua verified: lint 0, typecheck clean, 206/206 tests, build clean (45 routes).
- Masalah: respons hanya hidup dalam Google Sheets;
appendToSheetgagal = respons hilang selamanya (mesej "Saved locally but failed to sync" adalah palsu). - Migration
20260701010000_add_form_responses.sql: jadualform_responses(submission_id UNIQUE, data jsonb, sheet_sync_status pending/synced/failed) + partial index untuk cron +prune_form_responses()(400 hari) + RLS owner-only SELECT (tulis via service role sahaja, corak audit_logs). - Storage
lib/storage/form-responses.ts:insertFormResponsereturn'inserted' | 'duplicate' | 'error'(duplicate = unique violation 23505),markResponseSynced,markResponseSyncFailed(id, err, {final}),listPendingSyncResponses(join forms+settings sekali). - submitFormAction tulis ke DB SEBELUM Sheets; Sheets sync + webhooks + 3 emel berpindah ke
after()(responden tak lagi menunggu ~15s webhook; tiada lagi risiko serverless timeout). - Cron baharu
/api/cron/sync-responses(*/10 minit di vercel.json): retry row 'pending', refresh OAuth token, kekal gagal selamanya ditandafinal(tiada sheet URL / tiada kredensial). - Baseline migrasi: sedia ada
processed_atjuga di-backfill untuk transaksi completed supaya replay pertama selepas deploy tidak double-process.
- Masalah: replay webhook = +1 bulan percuma + emel berulang. JUGA bug tersembunyi: route guna
createClient()(anon, tiada cookie) tetapi RLS transactions/subscriptions owner/service_role sahaja — webhook gagal jumpa transaksi secara senyap dalam production. - Migration
20260701020000_payment_webhook_idempotency.sql: kolumprocessed_at+ backfill completed + unique indexprovider_reference. - Route:
processed_atdiset DALAM update yang sama dengan status completed (crash mid-handler tak boleh double-grant); duplicate →{success:true, duplicate:true}200 tanpa sebarang kesan sampingan; SEMUA query DB kini melaluicreateAdminClient(). - Order number:
KLIK-${randomUUID()}(tidak boleh berlanggar) menggantikan Date.now()+random(0-999). - Fake phone
+60123456789→ placeholder+60110000000yang jelas.
- Masalah: field wajib yang disembunyikan oleh conditional logic ditolak server-side ("X is required") walaupun responden tak pernah nampak medan itu.
- Modul tulen
lib/forms/validate-submission.ts(validateSubmission,isLayoutOnlyField): guna semulaevaluateConditionalyang sama dengan client, re-key input label→id, skip layout-only fields, kekalkan ReDoS cap 1000 aksara.submitFormAction+ test suite penuh (12 ujian) menggunakan modul ini.
- Client jana
crypto.randomUUID()sekali per page-load (sessionStorageklikform-sub-key-<formId>), hantar sebagai_submission_key; action terima parameter ketigaclientSubmissionIddan guna ia sebagai submission_id → unique constraint menelan double-click/double-send secara senyap (return success tanpa re-sync/re-email). Key dikosongkan selepas success supaya "Submit another response" dapat key baharu.
.github/workflows/ci.yml: lint → typecheck → test → build pada setiap push/PR ke master, npm cache, placeholder env. Fail quality gate tak lagi bergantung pada disiplin manual.
app/error.tsx(reset + digest ref),app/global-error.tsx(inline styles, html/body sendiri),app/not-found.tsx(404 branded). Sebelum ini runtime error = skrin crash default Next.
lib/constants/pricing.ts(PRO_PRICE) jadi satu-satunya sumber harga; digunakan oleh initiate route, pricing page, pricing modal, plan-card. Habis era harga hardcoded di 3 tempat + TIER_PRICING mati.
getFormById/getFormByShortCodedibalutcache()— public form page (generateMetadata + render) kini satu query + satu tier lookup sebelum dua.
- Skrip
typecheckbaharu;@eslint/eslintrc,@types/uuid,typescript-eslint,cross-envdipindah ke devDependencies (ia ada dalam dependencies sebelum ini).
Membolehkan pengguna memasukkan tajuk borang (Form Title) dalam 2 baris atau lebih dengan menekan Enter dalam Form Builder serta memastikan rendering merentas semua muka surat dipaparkan dengan betul.
- Form Builder (
app/builder/[id]/client.tsx):- Medan input tajuk ditukar daripada
<Input>(single-line) kepada<Textarea rows={2} className="resize-y min-h-[60px]">supaya pengguna boleh menekan Enter untuk baris kedua. - Nama fail muat turun QR kod disanitasi (
replace(/\r?\n/g, ' ')) untuk mengelakkan newline dalam nama fail.
- Medan input tajuk ditukar daripada
- Borang Awam (
app/(public)/form/[id]/client.tsx):- Ditambah kelas Tailwind
whitespace-pre-line break-wordspada<CardTitle>utama supaya line breaks (\n) dipaparkan pada baris baharu secara semulajadi. - Atribut
altpada imej disanitasi.
- Ditambah kelas Tailwind
- Papan Pemuka & Kad Borang (
components/dashboard/form-card.tsx&app/(dashboard)/responses/client.tsx):CardTitledikemas kini daripadatruncatekepadaline-clamp-2 break-words whitespace-pre-linesupaya kad borang boleh memaparkan sehingga 2 baris tajuk.
- Komponen & Muka Surat Berkaitan:
components/certificate-qr-card.tsx: Ditambahline-clamp-2 break-words whitespace-pre-linepada tajuk dan sanitasi nama fail muat turun QR.app/(public)/check/[formId]/client.tsx: Ditambahwhitespace-pre-line break-wordspada tajuk semakan sijil.app/(public)/verify/[id]/client.tsx: Ditambahwhitespace-pre-line break-wordspada tajuk verifikasi sijil.app/(dashboard)/responses/[id]/analytics/page.tsx: Ditambahwhitespace-pre-line break-wordspada tajuk analitik.actions/sheets.ts: Tajuk Google Sheet spreadsheet baru disanitasi dengan membuang newline.app/(public)/form/[id]/page.tsx&app/(public)/s/[code]/page.tsx:generateMetadatadisanitasi untuk membuang newline pada tag<title>/ OpenGraph.lib/email/index.ts: Subjek dan preheader email disanitasi bagi mengelakkan karakter newline dalam header emel.
- Verifikasi:
npm test: 206/206 lulus (25 test suites).npm run lint: 0 ralat / amaran.npm run typecheck: Bersih (0 errors).npm run build: Bersih (Next 16.2.6, Turbopack, 45 laluan).
- Deployment:
- Berjaya dideploy ke Vercel Production:
https://klikform-j7rwf9o0k-sofwan-jailanis-projects.vercel.app(Deployment ID:dpl_Ekc5nK9r81i7frCCUKrTWMeGc3C5). - Aliased terus ke domain pengeluaran:
https://www.klikform.com. - Cron
sync-responsesdivercel.jsondilaraskan ke harian (0 10 * * *) untuk menepati had pelan Vercel Hobby.
- Berjaya dideploy ke Vercel Production:
Membolehkan tajuk program (program name / {PROGRAM_NAME}) pada sijil digital (e-cert), pratonton pembina sijil (Certificate Builder), dan halaman pratonton sijil (Preview) dipaparkan dengan sempurna dalam 2 baris atau lebih apabila mengandungi baris baru (\n) atau teks panjang.
- Certificate Renderer (
components/certificates/renderer/index.tsx):- Menukar
whiteSpace: 'nowrap'kepada dinamik:whiteSpace: el.type === 'text' || el.type === 'placeholder' ? 'pre-line' : 'nowrap'danwordBreak: el.type === 'text' || el.type === 'placeholder' ? 'break-word' : undefined. - Menambah kelas
whitespace-pre-line break-wordspada bekas render teks & placeholder.
- Menukar
- Canvas Pembina Sijil (
app/(dashboard)/certificates/builder/[id]/client.tsx):- Menggantikan
whitespace-nowrappada elemen teks dan placeholder denganwhitespace-pre-line break-words.
- Menggantikan
- Halaman Pratonton Sijil (
app/(dashboard)/certificates/builder/[id]/preview/page.tsx):- Menggantikan
whitespace-nowrappada elemen teks dan placeholder denganwhitespace-pre-line break-words.
- Menggantikan
- Panel Ciri Pembina Sijil (
components/certificates/builder/properties.tsx):- Menukar
<Input>teks kepada<Textarea rows={2} className="resize-y min-h-[60px]">supaya pengguna boleh menekan Enter untuk memasukkan teks berbilang baris secara langsung.
- Menukar
- Templat Sijil Pra-Bina & Legasi (
components/certificates/templates/*.tsx&components/certificate-template.tsx):- Mengemas kini tajuk
{program}dan{name}dengan kelaswhitespace-pre-line break-wordspada semua 10 templat pra-bina (Classic,Corporate,Creative,Elegant,Minimalist,Modern,Nature,Premium,Royal,Vintage) dan templat URL legasi.
- Mengemas kini tajuk
- Verifikasi:
npm test: 206/206 lulus (25 test suites, termasuk ujian pensijilan multi-line).npm run lint: 0 ralat / amaran.npm run typecheck: Bersih (0 errors).npm run build: Bersih (Next 16.2.6, Turbopack, 45 laluan).
- Deployment:
- Berjaya dideploy ke Vercel Production:
https://klikform-3k76l654y-sofwan-jailanis-projects.vercel.app(Deployment ID:dpl_AJDoeTBJrfnNEdmnd2gN4MFHwPZF). - Aliased terus ke domain pengeluaran:
https://www.klikform.com.
- Berjaya dideploy ke Vercel Production:
Membina ciri mikro-landing page lengkap (Link-in-bio) yang membolehkan pengguna mengumpulkan borang KlikForm, pautan WhatsApp, pautan tersuai, dan media sosial dalam satu URL profil peribadi (cth: klikform.com/bio/username dan klikform.com/b/username).
- Pangkalan Data Supabase (
supabase/migrations/20260830000000_add_bio_links.sql):bio_pages:id,user_id,username(unique),title,bio,avatar_url,theme,theme_config,social_links,is_active,views, timestamps.bio_links:id,bio_page_id,user_id,type(link,whatsapp,form,header),title,url,icon,highlight,is_active,clicks,order_index, timestamps.- Indeks prestasi pada
(user_id),(username),(bio_page_id, order_index). - Polisi RLS: Pemilik ada akses CRUD penuh; pelawat awam dibenarkan SELECT pada halaman & pautan yang
is_active = true. - Trigger
updated_atdengansecurity definerdanset search_path = ''.
- Modul Tema & Utiliti (
lib/bio-links/themes.ts):- 8 Tema visual pra-bina:
Emerald Luxe(signature KlikForm),Onyx Dark,Sunset Glow,Deep Ocean,Minimal Light,Lavender Dusk,Cyber Neon,Midnight Gold. - 6 Gaya bentuk butang:
Full Pill,Rounded XL,Subtle Round,Outline Border,Elevated Shadow,Glassmorphism. - Fungsi penentu URL media sosial pintar
resolveSocialUrl(format nombor WhatsApp kewa.me, handle IG/TikTok/FB/X/Telegram/YouTube/LinkedIn/GitHub/Email/Website). - Validasi dan sanitasi slug username (
isValidBioUsername,cleanBioUsername).
- 8 Tema visual pra-bina:
- Lapisan Storan & Server Actions (
lib/storage/bio-links.ts&actions/bio-links.ts):getBioPages,getBioPageById,getBioPageByUsername(menggunakan service role admin client untuk pelawat awam bagi melepasi RLS),createBioPage,updateBioPage,deleteBioPage.createBioLink,updateBioLink,deleteBioLink,reorderBioLinks,incrementBioPageView,incrementBioLinkClick.- Gating had pelan langganan (
maxBioPages: 1untuk Free,-1untuk Pro/Enterprise).
- Dashboard & Interactive Builder (
app/(dashboard)/bio/&app/(dashboard)/bio-builder/[id]/):/bio: Kad profil bio, statistik jumlah paparan (views), penunjuk status draf/aktif, dialog Kod QR segera (SVG & muat turun PNG bersaiz tinggi), butang Salin Pautan./bio-builder/[id]: Pembina interaktif 2 lajur. Lajur kiri mengandungi tab Pautan (dengan@dnd-kitdrag-and-drop sortable, jenis WhatsApp direct, Form picker KlikForm), tab Reka Bentuk (8 preset tema & 6 bentuk butang), tab Profil & Media Sosial (11 platform sosial), dan tab Kongsi & Kod QR. Lajur kanan memaparkan Live Interactive Mobile Mockup yang responsif terhadap sebarang perubahan masa nyata.
- Halaman Awam (
app/(public)/bio/[username]/&app/(public)/b/[username]/):- Paparan ultra-responsif untuk pelawat awam dengan metadata dinamik OpenGraph dan Twitter card.
- Animasi lancar
framer-motion, penjejakan klik (click tracking), butang kongsi terapung (floating share button), dan lencana Powered by KlikForm.
- Ujian & Kualiti:
- Ujian unit di
tests/bio-links.test.tsdantests/bio-storage.test.ts. - 224 / 224 ujian unit lulus merentas 28 suite ujian.
- 0 ralat ESLint, 0 ralat TypeScript, 49 laluan dikompilasi bersih dalam Next.js 16 (Turbopack).
- Ujian unit di