plans
2026-05-03
2026 05 03 Trust Signals Plan

Trust Signals Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Implement trust signals throughout the booking flow and public pages. Payment logos, security badges, social proof stats to reduce booking anxiety.

Tech Stack: Next.js 16, Tailwind CSS v4, IconSymbol component.

Spec: docs/superpowers/specs/13-trust-signals.md


Business Summary

What this does: Displays payment method logos (OnePay, Visa, Mastercard, PayPal), a security badge ("256-bit SSL encryption"), and social proof stats (Google rating, guest count) throughout the booking flow and homepage to reduce booking anxiety.

Why it matters: In high-consideration purchases like event tickets, guests hesitate when they doubt security or question whether others have enjoyed the experience. Trust signals provide immediate reassurance at the moment of decision, reducing cart abandonment and increasing conversion.

Time to implement: 2-3 days | Complexity: Low-Medium

Dependencies: None — this is a standalone display layer with no dependencies on other plans

Task 1: Create Payment Logos Component

Files:

  • Create: apps/frontend/components/ui/payment-logos.tsx

  • Create: apps/frontend/components/ui/security-badge.tsx

  • Create: apps/frontend/components/ui/social-proof.tsx

  • Step 1: Create payment logos component

// apps/frontend/components/ui/payment-logos.tsx
"use client";
import { useTranslations } from "next-intl";
 
export function PaymentLogos({ size = "md" }: { size?: "sm" | "md" }) {
  const t = useTranslations("trustSignals.paymentLogos");
  const height = size === "sm" ? "h-5" : "h-6";
 
  return (
    <div className="flex items-center gap-3" data-testid="payment-logos">
      {/* OnePay */}
      <div className={`${height} flex items-center justify-center bg-white rounded px-2 py-1`}>
        <span className="text-xs font-bold text-blue-600">{t("onepay")}</span>
      </div>
      {/* Visa */}
      <svg
        className={`${height} text-white`}
        viewBox="0 0 50 16"
        fill="none"
        aria-label={t("visa")}
        role="img"
      >
        <text x="0" y="13" className="fill-current text-xs font-bold">VISA</text>
      </svg>
      {/* Mastercard */}
      <svg className={height} viewBox="0 0 30 20" aria-label={t("mastercard")} role="img">
        <circle cx="12" cy="10" r="9" fill="#EB001B" />
        <circle cx="18" cy="10" r="9" fill="#F79E1B" />
      </svg>
      {/* PayPal */}
      <div className={`${height} flex items-center justify-center`}>
        <span className="text-xs font-bold text-blue-700">{t("paypal")}</span>
      </div>
    </div>
  );
}
  • Step 2: Create security badge
// apps/frontend/components/ui/security-badge.tsx
"use client";
import { useTranslations } from "next-intl";
import { IconSymbol } from "~/components/ui/icon-symbol";
 
export function SecurityBadge() {
  const t = useTranslations("trustSignals.security");
 
  return (
    <div className="flex items-center gap-2 text-xs text-gray-400" data-testid="security-badge">
      <IconSymbol name="lock.fill" size={16} className="text-gray-400" data-testid="security-badge-icon" />
      <span data-testid="security-badge-text">{t("securePayment")}</span>
    </div>
  );
}
  • Step 3: Create social proof component
// apps/frontend/components/ui/social-proof.tsx
"use client";
import { useTranslations } from "next-intl";
import { IconSymbol } from "~/components/ui/icon-symbol";
 
export function SocialProof() {
  const t = useTranslations("trustSignals.socialProof");
 
  return (
    <div className="flex items-center gap-6" data-testid="social-proof">
      <div className="flex items-center gap-2">
        {/* 5 stars using IconSymbol — no hardcoded Unicode */}
        <div className="flex gap-0.5" data-testid="star-icons">
          {[1, 2, 3, 4, 5].map((i) => (
            <IconSymbol key={i} name="star.fill" size={14} className="text-accent" />
          ))}
        </div>
        <span className="text-sm text-gray-400">{t("googleRating")}</span>
      </div>
      <div className="text-sm text-gray-400">
        <span className="text-white font-bold">{t("guestCount")}</span> {t("happyGuests")}
      </div>
    </div>
  );
}
  • Step 4: Commit
git add apps/frontend/components/ui/payment-logos.tsx apps/frontend/components/ui/security-badge.tsx apps/frontend/components/ui/social-proof.tsx
git commit -m "feat(trust-signals): add payment logos, security badge, social proof"

Phase 2: Integrate Trust Signals

Task 2: Add Trust Signals to Booking Pages

  • Step 1: Add to checkout page

In checkout form (from booking-flow plan), add before the Pay button:

<div className="flex items-center justify-center gap-4 py-4 border-y border-border">
  <PaymentLogos size="sm" />
</div>
<SecurityBadge />
  • Step 2: Add to homepage footer

In homepage or footer component, add <SocialProof />.

  • Step 3: Commit
git add apps/frontend/app/[locale]/booking/page.tsx
git commit -m "feat(trust-signals): integrate trust signals into checkout"

Enrichment Sections

1. Zod Schemas

Trust signals are display-only components. However, if dynamic data is fetched from Google Places API in v2, the following schemas would apply:

// apps/frontend/lib/schemas/trust-signals.ts
import { z } from "zod";
 
// Schema for Google Places API response (future v2 integration)
export const GooglePlacesReviewSchema = z.object({
  rating: z.number().min(1).max(5),
  reviewCount: z.number().int().nonnegative(),
  lastUpdated: z.string(),
});
 
// Schema for static trust signal config (used in v1)
export const TrustSignalsConfigSchema = z.object({
  guestCount: z.number().int().nonnegative(),
  googleRating: z.number().min(0).max(5),
});
 
export type GooglePlacesReviewData = z.infer<typeof GooglePlacesReviewSchema>;
export type TrustSignalsConfig = z.infer<typeof TrustSignalsConfigSchema>;
// apps/frontend/lib/schemas/trust-signals-errors.ts
// Error codes for trust signals operations (v1 has no mutations, but future API calls may fail)
export const TrustSignalsErrorCode = {
  GOOGLE_PLACES_FETCH_FAILED: "TRUST_SIGNALS_GOOGLE_PLACES_FETCH_FAILED",
  GOOGLE_PLACES_QUOTA_EXCEEDED: "TRUST_SIGNALS_GOOGLE_PLACES_QUOTA_EXCEEDED",
  CONFIG_FETCH_FAILED: "TRUST_SIGNALS_CONFIG_FETCH_FAILED",
} as const;
type TrustSignalsError = keyof typeof TrustSignalsErrorCode;

2. Error Handling

Trust signal display components have no error states in v1 — they are static or fetched from external APIs. If Google Places API is integrated in future:

API CallError CodeDisplay
Google Places fetchTRUST_SIGNALS_GOOGLE_PLACES_FETCH_FAILEDShow cached/static values
Google Places fetchTRUST_SIGNALS_GOOGLE_PLACES_QUOTA_EXCEEDEDShow cached/static values
Config fetchTRUST_SIGNALS_CONFIG_FETCH_FAILEDShow static fallback values

Named error codes as const object:

// apps/frontend/lib/schemas/trust-signals-errors.ts
export const TrustSignalsErrorCode = {
  GOOGLE_PLACES_FETCH_FAILED: "TRUST_SIGNALS_GOOGLE_PLACES_FETCH_FAILED",
  GOOGLE_PLACES_QUOTA_EXCEEDED: "TRUST_SIGNALS_GOOGLE_PLACES_QUOTA_EXCEEDED",
  CONFIG_FETCH_FAILED: "TRUST_SIGNALS_CONFIG_FETCH_FAILED",
} as const;
type TrustSignalsError = keyof typeof TrustSignalsErrorCode;

Client-side error parsing: check err.message.startsWith(errorCode).

3. Convex Real-time Subscription Pattern

Not applicable — trust signal components are not real-time in v1. Static social proof values are hardcoded. However, if Google Places API is integrated in v2:

// v2 future: Google Places rating (real-time via React Query / SWR)
const { data: reviewData } = useSWR<GooglePlacesReviewData>(
  "google-places-review",
  fetchGooglePlacesRating,
  {
    revalidateOnFocus: false,
    dedupingInterval: 3600000, // 1 hour
    fallbackData: { rating: 4.8, reviewCount: 1200, lastUpdated: "" },
  },
);

v1 conclusion: No Convex real-time subscriptions needed. Trust signals are static display components.

4. Mobile/Responsive Considerations

ComponentMobile BehaviorDesktop Behavior
PaymentLogosflex-wrap wraps to 2 rows on <375px; size="sm" reduces height to h-5 (20px)Inline flex, full size
SecurityBadgeCentered below payment logos; text-xs same on all sizesSame
SocialProofflex-col on <320px (stars stack above count); otherwise flex-row inlineInline flex row

Breakpoints:

  • <320px: SocialProof stacks vertically (stars above count)
  • 320-374px: PaymentLogos wraps to 2 rows if needed
  • 375px+: Standard inline layouts

Touch targets: All interactive trust signal elements (none in v1) should have minimum 44x44px tap targets.

5. PWA / Offline Behavior

  • Static trust signals: Cached indefinitely by service worker (CacheFirst)
  • Google Places API (future v2): stale-while-revalidate with 1-hour max-age
  • All components: Work offline with cached/static data
// Service worker: CacheFirst for trust signal static assets
registerRoute(
  ({ request }) => request.destination === "document",
  new CacheFirst({ cacheName: "trust-signals-static" }),
);
 
// Fallback values for offline:
const FALLBACK_RATING = 4.8;
const FALLBACK_GUEST_COUNT = 1200;

6. i18n / next-intl Requirements

All user-facing strings must use getTranslations/useTranslations. Add to messages/en.json and messages/vi.json:

{
  "trustSignals": {
    "paymentLogos": {
      "onepay": "OnePay",
      "visa": "Visa",
      "mastercard": "Mastercard",
      "paypal": "PayPal"
    },
    "security": {
      "securePayment": "Secure payment — 256-bit SSL encryption"
    },
    "socialProof": {
      "googleRating": "4.8 on Google",
      "guestCount": "1,200+",
      "happyGuests": "happy guests"
    }
  }
}

Vietnamese:

{
  "trustSignals": {
    "paymentLogos": {
      "onepay": "OnePay",
      "visa": "Visa",
      "mastercard": "Mastercard",
      "paypal": "PayPal"
    },
    "security": {
      "securePayment": "Thanh toan bao mat — Ma hoa 256-bit SSL"
    },
    "socialProof": {
      "googleRating": "4.8 sao tren Google",
      "guestCount": "1,200+",
      "happyGuests": "khach hang vui ve"
    }
  }
}

Note: Social proof values (4.8 rating, 1,200+ guests) are hardcoded in v1. Future v2: fetch from Google Places API with caching.

7. Environment-Specific Configuration

// Server-only:
CLERK_SECRET_KEY=              — Convex server-side Clerk auth
 
// Client-safe (NEXT_PUBLIC_ prefix):
NEXT_PUBLIC_CONVEX_URL=        — Convex deployment URL
NEXT_PUBLIC_APP_URL=           — For meta tags, canonical URLs

Future v2 (Google Places API):

VariableRequiredDescription
GOOGLE_PLACES_API_KEYNoFor fetching live Google rating (v2)
NEXT_PUBLIC_GOOGLE_PLACES_API_KEYNoExposed for client-side Places API (v2)

8. TDD Test Cases

E2E Tests (Playwright) — User Expectation Format:

// apps/frontend/e2e/trust-signals.spec.ts
// TDD: Write test BEFORE implementation
// Run: npx playwright test e2e/trust-signals.spec.ts
 
import { test, expect } from "@playwright/test";
 
test.describe("Trust Signals — User Journeys", () => {
  // ─── TS-E2E-1: Checkout page ──────────────────────────────────
  test("TS-E2E-1.1: Payment logos visible on checkout above Pay button", async ({
    page,
  }) => {
    // Given: Guest is on the checkout/payment page
    // When: Page loads
    // Then: OnePay, Visa, Mastercard, PayPal logos visible
    await page.goto("/en/booking?step=payment");
    await expect(page.getByTestId("payment-logos")).toBeVisible();
    await expect(page.getByText("OnePay")).toBeVisible();
    await expect(page.getByText("Visa")).toBeVisible();
    await expect(page.getByText("PayPal")).toBeVisible();
  });
 
  test("TS-E2E-1.2: Security badge visible near payment form", async ({
    page,
  }) => {
    // Given: Guest is on the checkout/payment page
    // When: Page loads
    // Then: Lock icon + "Secure payment — 256-bit SSL encryption" text visible
    await page.goto("/en/booking?step=payment");
    await expect(page.getByTestId("security-badge")).toBeVisible();
    await expect(page.getByTestId("security-badge-text")).toBeVisible();
  });
 
  // ─── TS-E2E-2: Homepage footer ────────────────────────────────
  test("TS-E2E-2.1: Social proof visible in footer — star rating", async ({
    page,
  }) => {
    // Given: Guest is on homepage
    // When: Page loads
    // Then: Star rating + "4.8 on Google" visible in footer
    await page.goto("/en");
    const stars = page.getByTestId("star-icons");
    await expect(stars).toBeVisible();
    await expect(page.getByText(/4.8 on google/i)).toBeVisible();
  });
 
  test("TS-E2E-2.2: Social proof shows guest count", async ({ page }) => {
    // Given: Guest is on homepage
    // When: Page loads
    // Then: "1,200+ happy guests" visible
    await page.goto("/en");
    await expect(page.getByText(/1,200/i)).toBeVisible();
    await expect(page.getByText(/happy guests/i)).toBeVisible();
  });
 
  // ─── TS-E2E-3: Mobile layout ──────────────────────────────────
  test("TS-E2E-3.1: Payment logos don't overflow on mobile", async ({
    page,
  }) => {
    // Given: Mobile viewport (375px width)
    // When: Guest visits checkout page
    // Then: No horizontal scroll, logos fit within viewport
    await page.setViewportSize({ width: 375, height: 812 });
    await page.goto("/en/booking?step=payment");
    const logos = page.getByTestId("payment-logos");
    const box = await logos.boundingBox();
    expect(box?.width).toBeLessThanOrEqual(375);
  });
 
  test("TS-E2E-3.2: Social proof stacks on very small screens (<320px)", async ({
    page,
  }) => {
    // Given: Very small screen (320px width)
    // When: Guest visits homepage
    // Then: Social proof items stack vertically if needed
    await page.setViewportSize({ width: 320, height: 568 });
    await page.goto("/en");
    await expect(page.getByTestId("social-proof")).toBeVisible();
  });
 
  // ─── TS-E2E-4: i18n ──────────────────────────────────────────
  test("TS-E2E-4.1: Vietnamese locale — security badge in Vietnamese", async ({
    page,
  }) => {
    // Given: Vietnamese locale
    // When: Guest visits checkout page
    // Then: Security badge text in Vietnamese
    await page.goto("/vi/booking?step=payment");
    await expect(page.getByTestId("security-badge-text")).toContainText(
      /ma hoa 256-bit ssl/i,
    );
  });
 
  test("TS-E2E-4.2: Vietnamese locale — social proof in Vietnamese", async ({
    page,
  }) => {
    // Given: Vietnamese locale
    // When: Guest visits homepage
    // Then: Social proof text in Vietnamese
    await page.goto("/vi");
    await expect(page.getByText(/4.8 sao tren google/i)).toBeVisible();
    await expect(page.getByText(/khach hang vui ve/i)).toBeVisible();
  });
 
  // ─── TS-E2E-5: Offline behavior ────────────────────────────────
  test("TS-E2E-5.1: Trust signals visible when offline (cached)", async ({
    page,
    context,
  }) => {
    // Given: Guest has visited the page before (assets cached)
    // When: Guest goes offline and visits page again
    // Then: Payment logos and security badge still visible
    await context.setOffline(true);
    await page.goto("/en/booking?step=payment");
    await expect(page.getByTestId("payment-logos")).toBeVisible();
    await expect(page.getByTestId("security-badge")).toBeVisible();
  });
});

Component Tests (Vitest + RTL) — User Expectation Format:

// apps/frontend/__tests__/components/trust-signals.test.tsx
// TDD: Write test BEFORE implementation
// Run: npx vitest run __tests__/components/trust-signals.test.tsx
 
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { PaymentLogos } from "~/components/ui/payment-logos";
import { SecurityBadge } from "~/components/ui/security-badge";
import { SocialProof } from "~/components/ui/social-proof";
import { NextIntlClientProvider } from "next-intl";
import en from "~/messages/en.json";
 
const renderWithI18n = (component: React.ReactElement) => {
  return render(
    <NextIntlClientProvider locale="en" messages={{ trustSignals: en.trustSignals }}>
      {component}
    </NextIntlClientProvider>
  );
};
 
describe("PaymentLogos — User Expectations", () => {
 
  // ─── TS-UT-1: Payment logos rendering ─────────────────────────
  it("TS-UT-1.1: All payment method labels visible", () => {
    // Given: PaymentLogos component renders
    // When: Page loads
    // Then: OnePay, Visa, Mastercard, PayPal all visible
    renderWithI18n(<PaymentLogos />);
    expect(screen.getByText("OnePay")).toBeInTheDocument();
    expect(screen.getByText("PayPal")).toBeInTheDocument();
  });
 
  it("TS-UT-1.2: sm size renders smaller than md size", () => {
    // Given: PaymentLogos with size="sm"
    // When: Compared to size="md"
    // Then: sm size has smaller height than md size
    const { container: smContainer } = renderWithI18n(<PaymentLogos size="sm" />);
    const { container: mdContainer } = renderWithI18n(<PaymentLogos size="md" />);
    const smHeight = smContainer.querySelector("[class*='h-5']");
    const mdHeight = mdContainer.querySelector("[class*='h-6']");
    expect(smHeight).toBeInTheDocument();
    expect(mdHeight).toBeInTheDocument();
  });
 
  it("TS-UT-1.3: data-testid 'payment-logos' present on container", () => {
    // Given: PaymentLogos component renders
    // When: Page loads
    // Then: data-testid="payment-logos" is present for E2E targeting
    renderWithI18n(<PaymentLogos />);
    expect(screen.getByTestId("payment-logos")).toBeInTheDocument();
  });
});
 
describe("SecurityBadge — User Expectations", () => {
 
  // ─── TS-UT-2: Security badge rendering ───────────────────────
  it("TS-UT-2.1: Shows lock icon and secure payment text", () => {
    // Given: SecurityBadge component renders
    // When: Page loads
    // Then: Lock icon + "Secure payment — 256-bit SSL encryption" visible
    renderWithI18n(<SecurityBadge />);
    expect(screen.getByTestId("security-badge")).toBeInTheDocument();
    expect(screen.getByTestId("security-badge-text")).toContainText(/secure payment/i);
  });
 
  it("TS-UT-2.2: IconSymbol renders as accessible img role", () => {
    // Given: SecurityBadge component renders
    // When: Page loads
    // Then: IconSymbol renders with accessible role
    renderWithI18n(<SecurityBadge />);
    expect(screen.getByTestId("security-badge-icon")).toBeInTheDocument();
  });
 
  it("TS-UT-2.3: data-testid 'security-badge' present on container", () => {
    // Given: SecurityBadge component renders
    // When: Page loads
    // Then: data-testid="security-badge" is present for E2E targeting
    renderWithI18n(<SecurityBadge />);
    expect(screen.getByTestId("security-badge")).toBeInTheDocument();
  });
});
 
describe("SocialProof — User Expectations", () => {
 
  // ─── TS-UT-3: Social proof rendering ─────────────────────────
  it("TS-UT-3.1: Shows star rating and guest count", () => {
    // Given: SocialProof component renders
    // When: Page loads
    // Then: "4.8 on Google" and "1,200+ happy guests" visible
    renderWithI18n(<SocialProof />);
    expect(screen.getByText(/4.8 on google/i)).toBeInTheDocument();
    expect(screen.getByText(/1,200/i)).toBeInTheDocument();
  });
 
  it("TS-UT-3.2: Shows exactly 5 star icons via IconSymbol (no emoji)", () => {
    // Given: SocialProof component renders
    // When: Page loads
    // Then: 5 star icons rendered via IconSymbol, not emoji
    renderWithI18n(<SocialProof />);
    const icons = screen.getAllByTestId(/star-icon/);
    expect(icons.length).toBe(5);
    // Verify no emoji in the rendered output
    const html = screen.getByTestId("social-proof").innerHTML;
    expect(html).not.toMatch(/[\u{1F300}-\u{1F9FF}]/u);
  });
 
  it("TS-UT-3.3: Guest count uses bold number + regular text", () => {
    // Given: SocialProof component renders
    // When: Page loads
    // Then: "1,200+" in bold, "happy guests" in regular weight
    renderWithI18n(<SocialProof />);
    const guestCount = screen.getByText(/1,200/i);
    expect(guestCount).toHaveClass("font-bold");
    const happyGuests = screen.getByText(/happy guests/i);
    expect(happyGuests).not.toHaveClass("font-bold");
  });
 
  it("TS-UT-3.4: data-testid 'star-icons' present on star container", () => {
    // Given: SocialProof component renders
    // When: Page loads
    // Then: data-testid="star-icons" is present for E2E targeting
    renderWithI18n(<SocialProof />);
    expect(screen.getByTestId("star-icons")).toBeInTheDocument();
  });
});

9. Cross-Plan Dependencies

Depends OnRequired ByShared Schema/Integration
Checkout flowTrust SignalsPayment logos placed above Pay button
HomepageTrust SignalsSocial proof placed in footer
Booking flowTrust SignalsSecurity badge in payment step
Google Places API (future v2)Trust SignalsDynamic rating/count display

No hard schema dependencies — Trust Signals is a pure display layer with no mutations and no shared database schema with other plans.

10. Performance Considerations

  • Static components: No JS required for rendering — zero runtime cost
  • SVG icons: Inline SVGs for payment logos avoid extra network requests
  • Google Places API (future v2): Only fetch on page load with 1-hour cache to avoid API quota exhaustion
  • Image assets: None required in v1 — all logos use CSS/SVG
  • No console.log: These are pure display components with no logging
  • No as any: All components use proper TypeScript types
  • No Math.random(): Static values, no randomization needed
  • Cache-first strategy: Static assets cached indefinitely; no repeated requests
  • Bundle impact: Zero additional JS bundle — components are pure presentational

Acceptance Criteria

  1. Checkout page shows payment logos (OnePay, Visa, Mastercard, PayPal)
  2. Checkout page shows "Secure payment — 256-bit SSL encryption" badge
  3. Homepage or footer shows social proof stats (star rating, guest count)
  4. All trust signal components are reusable and consistent in design
  5. All strings use useTranslations — no hardcoded user-facing strings
  6. Stars rendered via IconSymbol — no emoji in UI
  7. All components have data-testid attributes for E2E targeting

Consistency Audit: trust-signals

P0 Violations (fixed in plan text)

#LocationIssueFix Applied
NoneNo P0 violations foundN/A — trust signals are display-only with no user input, mutations, auth, randomization, or dynamic routing

P1 Violations (fixed in plan text)

#LocationIssueFix Applied
NoneNo P1 violations foundN/A — no Google Places API used in v1; social proof values are hardcoded and will be fetched in v2

P0 Gaps (cannot fix in plan — requires codebase change)

#IssueAction Required
1Google Places API integration not yet implementedFuture v2 iteration: fetch live rating/count via Google Places API with server-side caching