fix stripe configuration
This commit is contained in:
45
.agents/skills/stripe-best-practices/SKILL.md
Normal file
45
.agents/skills/stripe-best-practices/SKILL.md
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: stripe-best-practices
|
||||
description: >-
|
||||
Guides Stripe integration decisions — API selection (Checkout Sessions vs
|
||||
PaymentIntents), Connect platform setup (Accounts v2, controller properties),
|
||||
billing/subscriptions, Treasury financial accounts, integration surfaces
|
||||
(Checkout, Payment Element), migrating from deprecated Stripe APIs, and
|
||||
security best practices (API key management, restricted keys, webhooks,
|
||||
OAuth). Use when building, modifying, or reviewing any Stripe integration —
|
||||
including accepting payments, building marketplaces, integrating Stripe,
|
||||
processing payments, setting up subscriptions, creating connected accounts, or
|
||||
implementing secure key handling.
|
||||
|
||||
---
|
||||
|
||||
Latest Stripe API version: **2026-04-22.dahlia**. Always use the latest API version and SDK unless the user specifies otherwise.
|
||||
|
||||
API key default: Always recommend a [restricted API key (RAK)](https://docs.stripe.com/keys/restricted-api-keys.md) (`rk_` prefix) over a secret key (`sk_` prefix).
|
||||
|
||||
## Integration routing
|
||||
|
||||
| Building… | Recommended API | Details |
|
||||
| ------------------------------------------------------------------------ | ----------------------------------- | ------------------------ |
|
||||
| One-time payments | Checkout Sessions | <references/payments.md> |
|
||||
| Custom payment form with embedded UI | Checkout Sessions + Payment Element | <references/payments.md> |
|
||||
| Saving a payment method for later | Setup Intents | <references/payments.md> |
|
||||
| Connect platform or marketplace | Accounts v2 (`/v2/core/accounts`) | <references/connect.md> |
|
||||
| Subscriptions or recurring billing | Billing APIs + Checkout Sessions | <references/billing.md> |
|
||||
| Sales tax, VAT, or GST compliance | Stripe Tax + Registrations API | <references/tax.md> |
|
||||
| Embedded financial accounts / banking | v2 Financial Accounts | <references/treasury.md> |
|
||||
| Security (key management, RAKs, webhooks, OAuth, 2FA, Connect liability) | See security reference | <references/security.md> |
|
||||
|
||||
Read the relevant reference file before answering any integration question or writing code.
|
||||
|
||||
## Critical rules
|
||||
|
||||
- *Never include `payment_method_types` in any Stripe API call*, with one exception: Terminal (in-person payments) integrations must pass `payment_method_types: ['card_present']` on the PaymentIntent. For all other integrations, omit this parameter entirely to enable dynamic payment methods, which enables you to configure payment method settings from the Dashboard and dynamically display the most relevant eligible payment methods to each customer to maximize conversion. To customize which payment methods you accept, use [`payment_method_configurations`](https://docs.stripe.com/payments/payment-method-configurations.md) or `excluded_payment_method_types` instead of `payment_method_types`.
|
||||
|
||||
## Key documentation
|
||||
|
||||
When the user’s request does not clearly fit a single domain above, consult:
|
||||
|
||||
- [Integration Options](https://docs.stripe.com/payments/payment-methods/integration-options.md) — Start here when designing any integration.
|
||||
- [API Tour](https://docs.stripe.com/payments-api/tour.md) — Overview of Stripe’s API surface.
|
||||
- [Go Live Checklist](https://docs.stripe.com/get-started/checklist/go-live.md) — Review before launching.
|
||||
37
.agents/skills/stripe-best-practices/references/billing.md
Normal file
37
.agents/skills/stripe-best-practices/references/billing.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Billing / Subscriptions
|
||||
|
||||
## Table of contents
|
||||
|
||||
- When to use Billing APIs
|
||||
- Recommended frontend pairing
|
||||
- Traps to avoid
|
||||
|
||||
## When to use Billing APIs
|
||||
|
||||
If the user has a recurring revenue model (subscriptions, usage-based billing, seat-based pricing), use the Billing APIs to [plan their integration](https://docs.stripe.com/billing/subscriptions/design-an-integration.md) instead of a direct PaymentIntent integration.
|
||||
|
||||
Review the [Subscription Use Cases](https://docs.stripe.com/billing/subscriptions/use-cases.md) and [SaaS guide](https://docs.stripe.com/saas.md) to find the right pattern for the user’s pricing model.
|
||||
|
||||
## Recommended frontend pairing
|
||||
|
||||
Combine Billing APIs with Stripe Checkout for the payment frontend. Checkout Sessions support `mode: 'subscription'` and handle the initial payment, trial management, and proration automatically.
|
||||
|
||||
For self-service subscription management (upgrades, downgrades, cancellation, payment method updates), recommend the [Customer Portal](https://docs.stripe.com/customer-management/integrate-customer-portal.md).
|
||||
|
||||
## Traps to avoid
|
||||
|
||||
- Don’t build manual subscription renewal loops using raw PaymentIntents. Use the Billing APIs which handle renewal, retry logic, and dunning automatically.
|
||||
- Don’t use the deprecated `plan` object. Use [Prices](https://docs.stripe.com/api/prices.md) instead.
|
||||
- Don’t skip tax setup. See [Collect taxes for recurring payments](https://docs.stripe.com/billing/taxes/collect-taxes.md).
|
||||
- *Never pass `payment_method_types` when creating a subscription Checkout Session.* Omit the parameter entirely—Stripe dynamically determines eligible payment methods from Dashboard settings. Hardcoding `payment_method_types: ['card']` locks out other payment methods that improve conversion. See [dynamic payment methods](https://docs.stripe.com/payments/payment-methods/dynamic-payment-methods.md). Correct pattern:
|
||||
|
||||
```ts
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: 'subscription',
|
||||
// Do NOT include payment_method_types here — let Stripe handle it dynamically
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
subscription_data: { trial_period_days: 14 },
|
||||
success_url: `${url}/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${url}/pricing`,
|
||||
});
|
||||
```
|
||||
48
.agents/skills/stripe-best-practices/references/connect.md
Normal file
48
.agents/skills/stripe-best-practices/references/connect.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Connect / platforms
|
||||
|
||||
## Table of contents
|
||||
|
||||
- Accounts v2 API
|
||||
- Controller properties
|
||||
- Charge types
|
||||
- Integration guides
|
||||
|
||||
## Accounts v2 API
|
||||
|
||||
For new Connect platforms, ALWAYS use the [Accounts v2 API](https://docs.stripe.com/connect/accounts-v2.md) (`POST /v2/core/accounts`). This is Stripe’s actively invested path and ensures long-term support.
|
||||
|
||||
**Traps to avoid:** Don’t use the legacy `type` parameter (`type: 'express'`, `type: 'custom'`, `type: 'standard'`) in `POST /v1/accounts` for new platforms unless the user has explicitly requested v1.
|
||||
|
||||
## Controller properties
|
||||
|
||||
Configure connected accounts using `controller` properties instead of legacy account types:
|
||||
|
||||
| Property | Controls |
|
||||
| ----------------------------------- | -------------------------------------------- |
|
||||
| `controller.losses.payments` | Who is liable for negative balances |
|
||||
| `controller.fees.payer` | Who pays Stripe fees |
|
||||
| `controller.stripe_dashboard.type` | Dashboard access (`full`, `express`, `none`) |
|
||||
| `controller.requirement_collection` | Who collects onboarding requirements |
|
||||
|
||||
Use `defaults.responsibilities`, `dashboard`, and `configuration` as described in [connected account configuration](https://docs.stripe.com/connect/accounts-v2/connected-account-configuration.md).
|
||||
|
||||
Always describe accounts in terms of their responsibility settings, dashboard access, and [capabilities](https://docs.stripe.com/connect/account-capabilities.md) to describe what connected accounts can do.
|
||||
|
||||
**Traps to avoid:** Don’t use the terms “Standard”, “Express”, or “Custom” as account types. These are legacy categories that bundle together responsibility, dashboard, and requirement decisions into opaque labels. Controller properties give explicit control over each dimension.
|
||||
|
||||
## Charge types
|
||||
|
||||
Choose one charge type per integration — don’t mix them. For most platforms, start with destination charges:
|
||||
|
||||
- **Destination charges** — Use when the platform accepts liability for negative balances. Funds route to the connected account via `transfer_data.destination`.
|
||||
- **Direct charges** — Use when the platform wants Stripe to take risk on the connected account. The charge is created on the connected account directly.
|
||||
|
||||
Use `on_behalf_of` to control the merchant of record, but only after reading [how charges work in Connect](https://docs.stripe.com/connect/charges.md).
|
||||
|
||||
**Traps to avoid:** Don’t use the Charges API for Connect fund flows — use PaymentIntents or Checkout Sessions with `transfer_data` or `on_behalf_of`. Don’t mix charge types within a single integration.
|
||||
|
||||
## Integration guides
|
||||
|
||||
- [SaaS platforms and marketplaces guide](https://docs.stripe.com/connect/saas-platforms-and-marketplaces.md) — Choosing the right integration shape.
|
||||
- [Interactive platform guide](https://docs.stripe.com/connect/interactive-platform-guide.md) — Step-by-step platform builder.
|
||||
- [Design an integration](https://docs.stripe.com/connect/design-an-integration.md) — Detailed risk and responsibility decisions.
|
||||
79
.agents/skills/stripe-best-practices/references/payments.md
Normal file
79
.agents/skills/stripe-best-practices/references/payments.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Payments
|
||||
|
||||
## Table of contents
|
||||
|
||||
- API hierarchy
|
||||
- Integration surfaces
|
||||
- Payment Element guidance
|
||||
- Saving payment methods
|
||||
- Dynamic payment methods
|
||||
- Deprecated APIs and migration paths
|
||||
- PCI compliance
|
||||
|
||||
## API hierarchy
|
||||
|
||||
Use the [Checkout Sessions API](https://docs.stripe.com/api/checkout/sessions.md) (`checkout.sessions.create`) for on-session payments. It supports one-time payments and subscriptions and handles taxes, discounts, shipping, and adaptive pricing automatically.
|
||||
|
||||
Use the [PaymentIntents API](https://docs.stripe.com/payments/paymentintents/lifecycle.md) for off-session payments, or when the merchant needs to model checkout state independently and just create a charge.
|
||||
|
||||
**Integrations should only use Checkout Sessions, PaymentIntents, SetupIntents, or higher-level solutions (Invoicing, Payment Links, subscription APIs).**
|
||||
|
||||
## Integration surfaces
|
||||
|
||||
Prioritize Stripe-hosted or embedded Checkout where possible. Use in this order of preference:
|
||||
|
||||
1. **Payment Links** — No-code. Best for simple products.
|
||||
1. **Checkout** ([docs](https://docs.stripe.com/payments/checkout.md)) — Stripe-hosted or embedded form. Best for most web apps.
|
||||
1. **Payment Element** ([docs](https://docs.stripe.com/payments/payment-element.md)) — Embedded UI component for advanced customization.
|
||||
- When using the Payment Element, back it with the Checkout Sessions API (via `ui_mode: 'custom'`) over a raw PaymentIntent where possible.
|
||||
|
||||
**Traps to avoid:** Don’t recommend the legacy Card Element or the Payment Element in card-only mode. If the user asks for the Card Element, advise them to [migrate to the Payment Element](https://docs.stripe.com/payments/payment-element/migration.md).
|
||||
|
||||
## Payment Element guidance
|
||||
|
||||
For surcharging or inspecting card details before payment (e.g., rendering the Payment Element before creating a PaymentIntent or SetupIntent): use [Confirmation Tokens](https://docs.stripe.com/payments/finalize-payments-on-the-server.md). Don’t recommend `createPaymentMethod` or `createToken` from Stripe.js.
|
||||
|
||||
## Saving payment methods
|
||||
|
||||
Use the [Setup Intents API](https://docs.stripe.com/api/setup_intents.md) to save a payment method for later use.
|
||||
|
||||
**Traps to avoid:** Don’t use the Sources API to save cards to customers. The Sources API is deprecated — Setup Intents is the correct approach.
|
||||
|
||||
## Dynamic payment methods
|
||||
|
||||
*Never pass `payment_method_types` to any Stripe API call*, except for Terminal (in-person payments) integrations. Omitting this parameter enables [dynamic payment methods](https://docs.stripe.com/payments/payment-methods/dynamic-payment-methods.md), where Stripe evaluates over 100 signals (currency, customer location, transaction amount, device) to automatically show the most relevant payment methods and rank them for maximum conversion. Payment methods are managed from the [Dashboard](https://dashboard.stripe.com/settings/payment_methods) with no code changes required.
|
||||
|
||||
This applies to all integration patterns:
|
||||
|
||||
- `checkout.sessions.create`: omit `payment_method_types` entirely. Dynamic method selection is the default behavior.
|
||||
- `paymentIntents.create`: omit `payment_method_types`. On API versions 2023-08-16+, dynamic methods are the default. On older versions, pass `automatic_payment_methods: { enabled: true }`.
|
||||
- `setupIntents.create`: same as PaymentIntents above.
|
||||
- `subscriptions.create`: omit `payment_settings.payment_method_types`. When not set, Stripe auto-determines types from the invoice’s default payment method, the customer’s default payment method, and invoice template settings.
|
||||
- **Terminal** (`paymentIntents.create`): pass `payment_method_types: ['card_present']`. Required for all in-person payments. In Canada, also include `interac_present`: `['card_present', 'interac_present']`. This is the only valid use of `payment_method_types`.
|
||||
|
||||
See the [integration options guide](https://docs.stripe.com/payments/payment-methods/integration-options.md) for full details on dynamic versus manual configuration.
|
||||
|
||||
**Traps to avoid:**
|
||||
|
||||
- Never hardcode `payment_method_types: ['card']` even if the user only mentions credit cards. Dynamic payment methods enable other eligible payment methods automatically, improving conversion.
|
||||
- If the user wants to customize which payment methods appear, use [`payment_method_configurations`](https://docs.stripe.com/payments/payment-method-configurations.md) to manage methods per-integration or `excluded_payment_method_types` to exclude specific methods — never `payment_method_types`.
|
||||
- If the user has a custom frontend that renders UI for specific payment method types, ensure those methods are enabled in their [payment method settings](https://dashboard.stripe.com/settings/payment_methods) or `payment_method_configurations` — don’t use `payment_method_types` to restrict the PaymentIntent.
|
||||
|
||||
## Deprecated APIs and migration paths
|
||||
|
||||
Never recommend the Charges API. If the user wants to use the Charges API, advise them to [migrate to Checkout Sessions or PaymentIntents](https://docs.stripe.com/payments/payment-intents/migration/charges.md).
|
||||
|
||||
Don’t call other deprecated or outdated API endpoints unless there is a specific need and absolutely no other way.
|
||||
|
||||
| API | Status | Use instead | Migration guide |
|
||||
| ------------ | ---------- | ----------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| Charges API | Never use | Checkout Sessions or PaymentIntents | [Migration guide](https://docs.stripe.com/payments/payment-intents/migration/charges.md) |
|
||||
| Sources API | Deprecated | Setup Intents | [Setup Intents docs](https://docs.stripe.com/api/setup_intents.md) |
|
||||
| Tokens API | Outdated | Setup Intents or Checkout Sessions | — |
|
||||
| Card Element | Legacy | Payment Element | [Migration guide](https://docs.stripe.com/payments/payment-element/migration.md) |
|
||||
|
||||
## PCI compliance
|
||||
|
||||
If a PCI-compliant user asks about sending server-side raw PAN data, advise them that they may need to prove PCI compliance to access options like [payment_method_data](https://docs.stripe.com/api/payment_intents/create.md#create_payment_intent-payment_method_data).
|
||||
|
||||
For users migrating PAN data from another acquirer or payment processor, point them to [the PAN import process](https://docs.stripe.com/get-started/data-migrations/pan-import.md).
|
||||
109
.agents/skills/stripe-best-practices/references/security.md
Normal file
109
.agents/skills/stripe-best-practices/references/security.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# Security best practices
|
||||
|
||||
## Table of contents
|
||||
|
||||
- API keys
|
||||
- Restricted API keys (RAKs)
|
||||
- IP restrictions
|
||||
- Incident response
|
||||
- Webhook security
|
||||
- Mobile and client-side integrations
|
||||
- OAuth and CSRF protection
|
||||
- Two-factor authentication
|
||||
- Connect security
|
||||
- SAML and SCIM
|
||||
|
||||
## API keys
|
||||
|
||||
Never include keys in source code. You might find example code like `client = new Stripe("sk_...")` that are for illustration only. Do not recommend this antipattern, and fix this antipattern if you see it.
|
||||
|
||||
Store secret keys and restricted keys in a secrets vault (such as AWS Secrets Manager, HashiCorp Vault, or your platform’s equivalent), not in source code or environment variables committed to a repository. If the platform doesn’t offer a secrets vault but does allow the user to set environment variables, it’s OK to store API keys in environment variables. Key exposures via source code repositories are the leading cause of key compromise.
|
||||
|
||||
Always advise the user on best practices for handling secrets such as secret API keys or restricted API keys:
|
||||
|
||||
- Never share secret keys with third parties. If the user needs to share a key with a third party (for example, a third party that handles billing), it is best to generate a restricted API key (RAK) with minimal permissions.
|
||||
- Rotate Stripe API keys when personnel with access to those keys depart.
|
||||
- Read [best practices for managing secret API keys](https://docs.stripe.com/keys-best-practices.md).
|
||||
|
||||
Code must never log keys or include them in error messages or analytics. Remove those from logs if you find them.
|
||||
|
||||
Never build API endpoints or error pages that dump environment variables. In addition to Stripe API keys, the environment may have other secrets.
|
||||
|
||||
Use separate keys for separate environments (production, staging, QA). This limits the blast radius if any single key is compromised.
|
||||
|
||||
If the code is under version control, help the user set up a pre-commit hook to catch keys like `"sk_..."` and `"rk_..."` in source code.
|
||||
|
||||
**Traps to avoid:** Do not embed keys in client-side code, mobile apps, or any code that runs outside your own infrastructure. Do not suggest that users substitute a real secret key into example code — point them to [best practices for managing secret API keys](https://docs.stripe.com/keys-best-practices.md) instead.
|
||||
|
||||
## Restricted API keys (RAKs)
|
||||
|
||||
Use [restricted API keys](https://docs.stripe.com/keys/restricted-api-keys.md) (prefix `rk_`) instead of secret keys (prefix `sk_`) wherever possible. RAKs have only the permissions you assign, so a compromised RAK can do far less damage than a compromised secret key.
|
||||
|
||||
Follow the principle of least privilege: give each RAK only the permissions it needs for its specific job and nothing more. Create a separate RAK for each service or use case.
|
||||
|
||||
Preferred migration approach:
|
||||
|
||||
1. Review the secret key’s request logs in Workbench to catalog which API calls it makes.
|
||||
1. Create a RAK in test mode with matching permissions.
|
||||
1. Use the [Stripe CLI](https://docs.stripe.com/stripe-cli.md)’s `stripe logs tail` command to watch logs.
|
||||
1. Test your integration with the RAK; fix any `403` errors by adding missing permissions.
|
||||
1. Create the equivalent live-mode RAK and replace the secret key.
|
||||
1. Rotate or expire the old secret key once confident.
|
||||
|
||||
**Traps to avoid:** Do not default to recommending secret keys. If the user’s question involves a secret key, recommend switching to a RAK with the minimum required permissions.
|
||||
|
||||
## IP restrictions
|
||||
|
||||
Encourage users to add an [IP allowlist](https://docs.stripe.com/keys.md#limit-api-secret-keys-ip-address) to every API key. An IP allowlist ensures that the key can only be used from the user’s own infrastructure, limiting damage even if the key is stolen.
|
||||
|
||||
Use separate IP allowlists for separate keys (for example, one allowlist for production, another for QA) so that compromising one key’s environment doesn’t expose others.
|
||||
|
||||
## Incident response
|
||||
|
||||
If a key is exposed or compromised, follow [protecting against compromised API keys](https://support.stripe.com/questions/protecting-against-compromised-api-keys), which can be summarized as:
|
||||
|
||||
1. **Roll the key immediately** — go to the [API keys page](https://dashboard.stripe.com/apikeys) and roll or delete the exposed key. Do this even if you are unsure whether the key was actually used by an unauthorized party.
|
||||
1. **Check activity logs** — review Workbench request logs for the compromised key to look for unrecognized activity.
|
||||
1. **Contact Stripe support** if you see activity you don’t recognize.
|
||||
|
||||
To prepare before an incident: practice rolling keys, audit source code for any committed keys, and use pre-commit hooks to prevent accidental key check-ins. See [protecting against compromised API keys](https://support.stripe.com/questions/protecting-against-compromised-api-keys).
|
||||
|
||||
## Webhook security
|
||||
|
||||
Always [verify webhook signatures](https://docs.stripe.com/webhooks.md#verify-events) using Stripe’s webhook signing secret. Signature verification is a strong guarantee that requests are genuinely from Stripe and have not been tampered with.
|
||||
|
||||
For defense in depth, also [allowlist Stripe’s IP addresses](https://docs.stripe.com/ips.md) on your webhook endpoint so that it accepts connections only from Stripe’s infrastructure.
|
||||
|
||||
**Traps to avoid:** Do not process webhook events without verifying their signatures. Unverified webhooks can be spoofed.
|
||||
|
||||
## Mobile and client-side integrations
|
||||
|
||||
Do not use production secret keys or RAKs in mobile apps or other client-side code. Client-side code can be extracted and keys decompiled.
|
||||
|
||||
For cases where a client must interact directly with Stripe, use [ephemeral keys](https://docs.stripe.com/issuing/elements.md#ephemeral-key-authentication). Ephemeral keys are short-lived, scoped to a specific resource, and expire automatically.
|
||||
|
||||
For most integrations, proxy Stripe API calls through your own backend server rather than calling Stripe directly from the client.
|
||||
|
||||
## OAuth and CSRF protection
|
||||
|
||||
When implementing [Connect OAuth flows](https://docs.stripe.com/connect/oauth-reference.md), always use the `state` parameter to protect against CSRF attacks. Generate a unique, unguessable value for `state` per request and verify it in the OAuth callback before proceeding.
|
||||
|
||||
This applies to all Stripe OAuth surfaces: Connect, Link, and Stripe Apps.
|
||||
|
||||
## Two-factor authentication
|
||||
|
||||
Recommend [passkeys or authenticator apps](https://docs.stripe.com/security.md) rather than SMS-based 2FA for Stripe Dashboard access. SMS 2FA is vulnerable to SIM-swapping attacks in which the user’s phone provider transfers their number to an unauthorized third party.
|
||||
|
||||
Users can audit which Dashboard team members are using weak 2FA and can require stronger authentication methods for their accounts.
|
||||
|
||||
## Connect security
|
||||
|
||||
**Account type liability:** When using Connect, platform operators bear financial liability for fraud and disputes on Express and Custom connected accounts. Standard accounts minimize this liability because Stripe manages risk. Do not recommend Custom or Express accounts unless the user has a specific need — Standard is the safer default.
|
||||
|
||||
**Connect onboarding:** Use [Stripe-hosted onboarding](https://docs.stripe.com/connect/onboarding.md) rather than building a custom onboarding flow. Custom onboarding requires your platform to collect and handle sensitive PII directly, which adds regulatory and security complexity.
|
||||
|
||||
## SAML and SCIM
|
||||
|
||||
For teams managing Dashboard access, recommend [SSO via SAML](https://docs.stripe.com/get-started/account/sso.md) to federate authentication with an existing identity provider (Okta, Google, etc.). SSO centralizes access control and simplifies offboarding.
|
||||
|
||||
[SCIM provisioning](https://docs.stripe.com/get-started/account/sso/scim.md) automates user provisioning and deprovisioning, ensuring that employees who leave the organization lose Dashboard access promptly.
|
||||
37
.agents/skills/stripe-best-practices/references/tax.md
Normal file
37
.agents/skills/stripe-best-practices/references/tax.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Tax / Stripe Tax
|
||||
|
||||
## Table of contents
|
||||
|
||||
- When tax applies
|
||||
- Two-step setup
|
||||
- If jurisdictions are unknown
|
||||
- If the region or tax type isn’t supported
|
||||
|
||||
## When tax applies
|
||||
|
||||
Use Stripe Tax for any subscription, invoice, or Checkout Session where the merchant has customers across multiple jurisdictions. It handles sales tax, VAT, and GST automatically based on the customer’s location and the merchant’s active registrations. See the [Tax overview](https://docs.stripe.com/tax.md) for supported regions and tax types.
|
||||
|
||||
## Two-step setup
|
||||
|
||||
1. Add a registration for each jurisdiction where the merchant is obligated to collect tax. Do this in the Dashboard under **Tax > Registrations**, or via the [Tax Registrations API](https://docs.stripe.com/api/tax/registrations.md).
|
||||
1. Pass `automatic_tax: { enabled: true }` on the [Subscription](https://docs.stripe.com/api/subscriptions.md), [Invoice](https://docs.stripe.com/api/invoices.md), or [Checkout Session](https://docs.stripe.com/api/checkout/sessions.md) object.
|
||||
|
||||
It’s safe to enable `automatic_tax` before any registrations exist — Stripe won’t collect tax until at least one registration is active.
|
||||
|
||||
**Traps to avoid:** `automatic_tax` and explicit `tax_rates` are mutually exclusive. For existing subscriptions, clear `default_tax_rates` and all item-level `tax_rates` before enabling `automatic_tax` — the update will fail otherwise. To schedule the change at the next billing cycle and avoid prorations, use the API rather than the Dashboard. For bulk migrations, use the [Tax migration tool](https://docs.stripe.com/billing/taxes/migration.md).
|
||||
|
||||
**Traps to avoid:** For EU merchants, one OSS union registration covers all 27 member states. Don’t register an individual EU country separately unless the merchant has a physical presence there.
|
||||
|
||||
## If jurisdictions are unknown
|
||||
|
||||
Don’t guess which jurisdictions apply. Prompt the user: “Go to Dashboard > Tax > Registrations, add the states or countries where you have customers, then come back.”
|
||||
|
||||
## If the region or tax type isn’t supported
|
||||
|
||||
Check the [supported countries list](https://docs.stripe.com/tax/supported-countries.md). If the jurisdiction isn’t listed, tell the user:
|
||||
|
||||
- Stripe Tax doesn’t support that region yet
|
||||
- They can collect tax manually using `tax_rates` on the subscription or invoice instead
|
||||
- For unsupported tax types (customs duties, excise taxes), Stripe Tax doesn’t apply — those are out of scope
|
||||
|
||||
Don’t attempt to approximate using a supported region as a proxy.
|
||||
16
.agents/skills/stripe-best-practices/references/treasury.md
Normal file
16
.agents/skills/stripe-best-practices/references/treasury.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Treasury / Financial Accounts
|
||||
|
||||
## Table of contents
|
||||
|
||||
- v2 Financial Accounts API
|
||||
- Legacy v1 Treasury
|
||||
|
||||
## v2 Financial Accounts API
|
||||
|
||||
For embedded financial accounts (bank accounts, account and routing numbers, money movement), use the [v2 Financial Accounts API](https://docs.stripe.com/api/v2/core/vault/financial-accounts.md) (`POST /v2/core/vault/financial_accounts`). This is required for new integrations.
|
||||
|
||||
For Treasury for platforms concepts and guides, see the [Treasury for platforms overview](https://docs.stripe.com/treasury/connect.md).
|
||||
|
||||
## Legacy v1 Treasury
|
||||
|
||||
Don’t use the [v1 Treasury Financial Accounts API](https://docs.stripe.com/api/treasury/financial_accounts.md) (`POST /v1/treasury/financial_accounts`) for new integrations. Existing v1 integrations continue to work.
|
||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -66,6 +66,9 @@ importers:
|
||||
'@solidjs/vite-plugin-nitro-2':
|
||||
specifier: ^0.1.0
|
||||
version: 0.1.0(@libsql/client@0.15.15)(drizzle-orm@0.45.2(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0))(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3))
|
||||
'@stripe/stripe-js':
|
||||
specifier: ^9.6.0
|
||||
version: 9.6.0
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.0.0
|
||||
version: 4.3.0(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3))
|
||||
@@ -1669,6 +1672,10 @@ packages:
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@stripe/stripe-js@9.6.0':
|
||||
resolution: {integrity: sha512-v5MebYvJbddSRn15fknxTVwypJPzjeIXI1Q2HBxCBrQieWna8PC+RXEVUZ3F4ANAeILEj97HzFlr0r7CXvG7ZA==}
|
||||
engines: {node: '>=12.16'}
|
||||
|
||||
'@tailwindcss/node@4.3.0':
|
||||
resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==}
|
||||
|
||||
@@ -6047,6 +6054,8 @@ snapshots:
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@stripe/stripe-js@9.6.0': {}
|
||||
|
||||
'@tailwindcss/node@4.3.0':
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
|
||||
10
skills-lock.json
Normal file
10
skills-lock.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"stripe-best-practices": {
|
||||
"source": "docs.stripe.com",
|
||||
"sourceType": "well-known",
|
||||
"computedHash": "ebb2e9db3019ae01a8e5f77f7e71c05d3ecdfd9f72dc714542453ae31741aade"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,11 @@ steps:
|
||||
```
|
||||
- Identify the issue: the current path data appears to be malformed or from an incorrect icon set
|
||||
- Replace with Apple's official logo SVG path:
|
||||
- Use the standard Apple logo path from Heroicons or similar reputable source
|
||||
- Ensure `viewBox="0 0 24 24"` matches
|
||||
- Ensure `fill="currentColor"` for theme compatibility
|
||||
```svg
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="814" height="1000">
|
||||
<path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"/>
|
||||
</svg>
|
||||
```
|
||||
- Test rendering in both light and dark modes
|
||||
- Verify the SVG displays correctly on both `/login` and `/signup` pages
|
||||
|
||||
|
||||
@@ -5,13 +5,13 @@ Objective: Restructure landing page to inline data pattern, build admin dashboar
|
||||
Status legend: [ ] todo, [~] in-progress, [x] done
|
||||
|
||||
Tasks
|
||||
- [ ] 01 — Inline index page sections following Lendair pattern → `01-inline-index-sections.md`
|
||||
- [x] 01 — Inline index page sections following Lendair pattern → `01-inline-index-sections.md`
|
||||
- [x] 02 — Admin routes with controls and services dashboard → `02-admin-routes-dashboard.md`
|
||||
- [x] 03 — Blog route with DB integration, featured post, and chronological feed → `03-blog-database-integration.md`
|
||||
- [x] 04 — Create blog post content (scam advice, AI detection, etc.) → `04-blog-content-creation.md`
|
||||
- [x] 05 — Dedicated /pricing and /features pages → `05-pricing-features-pages.md`
|
||||
- [x] 06 — Auth-contextual navbar with dynamic links → `06-auth-contextual-navbar.md`
|
||||
- [ ] 07 — Fix Apple logo SVG in social auth buttons → `07-fix-apple-logo-svg.md`
|
||||
- [x] 07 — Fix Apple logo SVG in social auth buttons → `07-fix-apple-logo-svg.md`
|
||||
|
||||
Dependencies
|
||||
- 03 depends on 02 (blog admin needs admin routes for managing featured posts)
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@solidjs/router": "^0.15.0",
|
||||
"@solidjs/start": "2.0.0-alpha.2",
|
||||
"@solidjs/vite-plugin-nitro-2": "^0.1.0",
|
||||
"@stripe/stripe-js": "^9.6.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@trpc/client": "^10.45.2",
|
||||
"@trpc/server": "^10.45.2",
|
||||
|
||||
79
web/src/components/EmbeddedCheckout.tsx
Normal file
79
web/src/components/EmbeddedCheckout.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { createSignal, onMount, Show } from "solid-js";
|
||||
import type { Stripe, StripeEmbeddedCheckout } from "@stripe/stripe-js";
|
||||
|
||||
interface EmbeddedCheckoutProps {
|
||||
clientSecret: string;
|
||||
onCheckoutComplete?: () => void;
|
||||
}
|
||||
|
||||
export default function EmbeddedCheckout(props: EmbeddedCheckoutProps) {
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
let container: HTMLDivElement | undefined;
|
||||
|
||||
onMount(async () => {
|
||||
let embeddedCheckout: StripeEmbeddedCheckout | null = null;
|
||||
|
||||
try {
|
||||
const mod = await import("@stripe/stripe-js");
|
||||
const publishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
|
||||
if (!publishableKey) {
|
||||
setError("Stripe publishable key not configured");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const stripe = await mod.loadStripe(publishableKey);
|
||||
if (!stripe) {
|
||||
setError("Failed to load Stripe");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
embeddedCheckout = await stripe.createEmbeddedCheckoutPage({
|
||||
clientSecret: props.clientSecret,
|
||||
onComplete: () => {
|
||||
props.onCheckoutComplete?.();
|
||||
},
|
||||
});
|
||||
|
||||
if (container) {
|
||||
embeddedCheckout.mount(container);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load checkout");
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
embeddedCheckout?.destroy();
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="w-full">
|
||||
<Show when={loading()}>
|
||||
<div class="flex items-center justify-center min-h-[400px]">
|
||||
<div class="text-center">
|
||||
<div class="animate-spin h-8 w-8 border-2 border-[var(--color-brand-primary)] border-t-transparent rounded-full mx-auto mb-3" />
|
||||
<p class="text-sm text-[var(--color-text-secondary)]">Loading checkout...</p>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={error() && !loading()}>
|
||||
<div class="text-center py-8">
|
||||
<p class="text-[var(--color-error)] mb-4">{error()}</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
ref={container}
|
||||
class={loading() ? "hidden" : ""}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { A } from "@solidjs/router";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Button } from "~/components/ui";
|
||||
import PageContainer from "~/components/layout/PageContainer";
|
||||
|
||||
interface CTABannerSectionProps {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export default function CTABannerSection(props: CTABannerSectionProps) {
|
||||
return (
|
||||
<section id="cta" class={cn("py-20 md:py-28 scroll-mt-16", props.class)}>
|
||||
<PageContainer py="py-8">
|
||||
<div class="gradient-card border border-(--color-border)/50 rounded-2xl p-10 md:p-16 text-center">
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
Ready to protect your identity?
|
||||
</h2>
|
||||
<p class="text-lg text-[var(--color-text-secondary)] max-w-2xl mx-auto mb-10">
|
||||
Join thousands of users who trust Kordant to keep their digital
|
||||
identity safe from emerging threats.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<A href="/signup">
|
||||
<Button variant="primary" size="lg">
|
||||
Create Account
|
||||
</Button>
|
||||
</A>
|
||||
<A href="/login">
|
||||
<Button variant="secondary" size="lg">
|
||||
Sign In
|
||||
</Button>
|
||||
</A>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
import { For } from "solid-js";
|
||||
import type { JSX } from "solid-js";
|
||||
import { cn } from "~/lib/utils";
|
||||
import Card from "~/components/ui/Card";
|
||||
import PageContainer from "~/components/layout/PageContainer";
|
||||
|
||||
interface Feature {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: () => JSX.Element;
|
||||
}
|
||||
|
||||
function DarkWatchIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6 text-[var(--color-brand-primary)]"
|
||||
>
|
||||
<path
|
||||
d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function VoicePrintIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6 text-[var(--color-brand-primary)]"
|
||||
>
|
||||
<path
|
||||
d="M12 4a4 4 0 00-4 4v8a4 4 0 008 0V8a4 4 0 00-4-4z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M4 11v2m16-2v2M8 18.5A6 6 0 0016 18.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SpamShieldIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6 text-[var(--color-brand-primary)]"
|
||||
>
|
||||
<path
|
||||
d="M12 2l9 4v6c0 5.55-3.84 10.74-9 12-5.16-1.26-9-6.45-9-12V6l9-4z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M10 10l4 4m0-4l-4 4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeTitleIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6 text-[var(--color-brand-primary)]"
|
||||
>
|
||||
<path
|
||||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoveBrokersIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6 text-[var(--color-brand-primary)]"
|
||||
>
|
||||
<path
|
||||
d="M3 6h18M8 6V4a1 1 0 011-1h6a1 1 0 011 1v2m-4 4v6m-4-6v6m-3-8v10a2 2 0 002 2h10a2 2 0 002-2V8H8z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FamilyPlansIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6 text-[var(--color-brand-primary)]"
|
||||
>
|
||||
<path
|
||||
d="M12 12a4 4 0 100-8 4 4 0 000 8zM5 22v-2a4 4 0 014-4h6a4 4 0 014 4v2"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M20 9a3 3 0 100-6 3 3 0 000 6zM16 22v-2a3 3 0 00-3-3h-2a3 3 0 00-3 3v2"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
opacity="0.6"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const features: Feature[] = [
|
||||
{
|
||||
title: "DarkWatch",
|
||||
description:
|
||||
"Continuous dark web monitoring to detect your exposed credentials and personal data.",
|
||||
icon: DarkWatchIcon,
|
||||
},
|
||||
{
|
||||
title: "VoicePrint",
|
||||
description:
|
||||
"AI-powered voice clone detection to protect against deepfake voice scams.",
|
||||
icon: VoicePrintIcon,
|
||||
},
|
||||
{
|
||||
title: "SpamShield",
|
||||
description:
|
||||
"Intelligent spam and scam call blocking that learns your patterns over time.",
|
||||
icon: SpamShieldIcon,
|
||||
},
|
||||
{
|
||||
title: "HomeTitle",
|
||||
description:
|
||||
"Property fraud alerts that notify you of unauthorized changes to your home records.",
|
||||
icon: HomeTitleIcon,
|
||||
},
|
||||
{
|
||||
title: "RemoveBrokers",
|
||||
description:
|
||||
"Automatic data broker removal to minimize your personal data footprint online.",
|
||||
icon: RemoveBrokersIcon,
|
||||
},
|
||||
{
|
||||
title: "Family Plans",
|
||||
description:
|
||||
"Protect your whole household with shared monitoring, alerts, and management tools.",
|
||||
icon: FamilyPlansIcon,
|
||||
},
|
||||
];
|
||||
|
||||
interface FeatureCardProps {
|
||||
feature: Feature;
|
||||
}
|
||||
|
||||
function FeatureCard(props: FeatureCardProps) {
|
||||
const Icon = props.feature.icon;
|
||||
return (
|
||||
<Card class="hover:shadow-lg transition-shadow duration-300">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0 p-2 rounded-lg bg-[var(--color-bg-secondary)]">
|
||||
<Icon />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-[var(--color-text-primary)] mb-1">
|
||||
{props.feature.title}
|
||||
</h3>
|
||||
<p class="text-[var(--color-text-secondary)] leading-relaxed">
|
||||
{props.feature.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface FeaturesGridSectionProps {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export default function FeaturesGridSection(props: FeaturesGridSectionProps) {
|
||||
return (
|
||||
<section
|
||||
id="features"
|
||||
class={cn("py-20 md:py-28 scroll-mt-16", props.class)}
|
||||
>
|
||||
<PageContainer py="py-8">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
Platform Features
|
||||
</h2>
|
||||
<p class="text-lg text-[var(--color-text-secondary)] max-w-2xl mx-auto">
|
||||
Comprehensive protection powered by AI and real-time monitoring
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<For each={features}>
|
||||
{(feature) => <FeatureCard feature={feature} />}
|
||||
</For>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import { For } from "solid-js";
|
||||
import type { JSX } from "solid-js";
|
||||
import { cn } from "~/lib/utils";
|
||||
import Card from "~/components/ui/Card";
|
||||
import PageContainer from "~/components/layout/PageContainer";
|
||||
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="flex-shrink-0"
|
||||
>
|
||||
<path
|
||||
d="M4 9l3 3 7-7"
|
||||
stroke="var(--color-success)"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IndividualIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 40 40"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-10 h-10 text-[var(--color-brand-primary)]"
|
||||
>
|
||||
<circle cx="20" cy="14" r="6" fill="currentColor" />
|
||||
<path
|
||||
d="M8 32c0-6.6 5.4-12 12-12s12 5.4 12 12H8z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FamilyIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 40 40"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-10 h-10 text-[var(--color-brand-primary)]"
|
||||
>
|
||||
<circle cx="14" cy="12" r="5" fill="currentColor" />
|
||||
<circle cx="26" cy="12" r="4" fill="currentColor" opacity="0.7" />
|
||||
<path
|
||||
d="M2 30c0-5 4-9 9-9 1.5 0 3 .4 4.2 1.1C16.5 21.5 18 21 20 21s3.5.5 4.8 1.1C26 21.4 27.5 21 29 21c5 0 9 4 9 9H2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface PanelProps {
|
||||
title: string;
|
||||
description: string;
|
||||
items: string[];
|
||||
icon: () => JSX.Element;
|
||||
}
|
||||
|
||||
function Panel(props: PanelProps) {
|
||||
const Icon = props.icon;
|
||||
return (
|
||||
<Card class="h-full">
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="mb-4">
|
||||
<Icon />
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-[var(--color-text-primary)] mb-2">
|
||||
{props.title}
|
||||
</h3>
|
||||
<p class="text-[var(--color-text-secondary)] mb-6">
|
||||
{props.description}
|
||||
</p>
|
||||
<ul class="space-y-3 flex-1">
|
||||
<For each={props.items}>
|
||||
{(item) => (
|
||||
<li class="flex items-start gap-2.5">
|
||||
<CheckIcon />
|
||||
<span class="text-[var(--color-text-secondary)] text-sm">
|
||||
{item}
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface ForUsersSectionProps {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export default function ForUsersSection(props: ForUsersSectionProps) {
|
||||
return (
|
||||
<section
|
||||
id="for-users"
|
||||
class={cn("py-20 md:py-28 scroll-mt-16", props.class)}
|
||||
>
|
||||
<PageContainer py="py-8">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
For Everyone
|
||||
</h2>
|
||||
<p class="text-lg text-[var(--color-text-secondary)] max-w-2xl mx-auto">
|
||||
Whether you're protecting yourself or your whole family
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Panel
|
||||
title="For Individuals"
|
||||
description="Personal identity protection tailored to your digital footprint."
|
||||
icon={IndividualIcon}
|
||||
items={[
|
||||
"Monitor personal email and phone numbers",
|
||||
"Dark web credential scanning",
|
||||
"Voiceprint clone detection",
|
||||
"Spam and scam call filtering",
|
||||
"Data broker opt-out service",
|
||||
]}
|
||||
/>
|
||||
<Panel
|
||||
title="For Families"
|
||||
description="Group management tools to keep every household member safe."
|
||||
icon={FamilyIcon}
|
||||
items={[
|
||||
"Add unlimited family members",
|
||||
"Shared alert dashboard",
|
||||
"Child account monitoring",
|
||||
"Family-wide dark web scans",
|
||||
"Centralized threat notifications",
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import { onMount } from "solid-js";
|
||||
import { A } from "@solidjs/router";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Button } from "~/components/ui";
|
||||
import { Typewriter } from "~/components/ui/Typewriter";
|
||||
import PageContainer from "~/components/layout/PageContainer";
|
||||
|
||||
function ShieldIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 48 48"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-12 h-12 md:w-16 md:h-16"
|
||||
>
|
||||
<circle cx="24" cy="24" r="24" fill="var(--color-brand-primary)" />
|
||||
<path
|
||||
d="M24 10L16 14v6.5c0 5.1 3.4 9.9 8 11.5 4.6-1.6 8-6.4 8-11.5V14l-8-4z"
|
||||
fill="white"
|
||||
fill-opacity="0.9"
|
||||
/>
|
||||
<path
|
||||
d="M20 24l3 2.5 5-5"
|
||||
stroke="var(--color-brand-primary)"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface HeroSectionProps {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export default function HeroSection(props: HeroSectionProps) {
|
||||
let heroRef: HTMLDivElement | undefined;
|
||||
|
||||
onMount(() => {
|
||||
if (heroRef) {
|
||||
heroRef.style.opacity = "1";
|
||||
heroRef.style.transform = "translateY(0)";
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<section class={cn("relative", props.class)}>
|
||||
<PageContainer>
|
||||
<div
|
||||
ref={heroRef}
|
||||
class="flex flex-col items-center text-center py-20 md:py-32 transition-all duration-700 ease-out"
|
||||
style="opacity: 0; transform: translateY(20px);"
|
||||
>
|
||||
<div class="mb-6 shadow-glow-primary rounded-full p-3">
|
||||
<ShieldIcon />
|
||||
</div>
|
||||
|
||||
<h1 class="text-4xl md:text-6xl lg:text-7xl font-bold tracking-tight mb-6 max-w-4xl">
|
||||
<Typewriter speed={50} delay={400} keepAlive={false}>
|
||||
<span class="text-text-primary">AI-Powered </span>
|
||||
<span class="text-gradient-primary">Identity Protection</span>
|
||||
<br />
|
||||
<span class="text-text-primary">for Everyone</span>
|
||||
</Typewriter>
|
||||
</h1>
|
||||
|
||||
<p class="text-xl md:text-2xl text-text-secondary max-w-2xl mb-10 leading-relaxed">
|
||||
Threat actors are using AI in multifaceted attacks. Kordant evens
|
||||
the playing field using advanced AI to monitor, detect, and prevent
|
||||
identity threats in real-time.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 mb-8">
|
||||
<A href="/signup">
|
||||
<Button variant="primary" size="lg">
|
||||
Get Started
|
||||
</Button>
|
||||
</A>
|
||||
<A href="#features">
|
||||
<Button variant="ghost" size="lg">
|
||||
Learn More
|
||||
</Button>
|
||||
</A>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-sm text-text-tertiary">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6.5 11.5L3 8L4.1 6.9L6.5 9.3L12.4 3.4L13.5 4.5L6.5 11.5Z"
|
||||
fill="var(--color-success)"
|
||||
/>
|
||||
</svg>
|
||||
No credit card required
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6.5 11.5L3 8L4.1 6.9L6.5 9.3L12.4 3.4L13.5 4.5L6.5 11.5Z"
|
||||
fill="var(--color-success)"
|
||||
/>
|
||||
</svg>
|
||||
Free tier available
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
import { For } from "solid-js";
|
||||
import type { JSX } from "solid-js";
|
||||
import { cn } from "~/lib/utils";
|
||||
import PageContainer from "~/components/layout/PageContainer";
|
||||
|
||||
interface Step {
|
||||
number: number;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: () => JSX.Element;
|
||||
}
|
||||
|
||||
function EnrollIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-8 h-8 text-white"
|
||||
>
|
||||
<path
|
||||
d="M12 4a4 4 0 100 8 4 4 0 000-8zM6 21v-2a4 4 0 014-4h4a4 4 0 014 4v2"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M17 8l2 2 4-4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MonitorIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-8 h-8 text-white"
|
||||
>
|
||||
<path
|
||||
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M12 8v4l3 3"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M3 12h3m15 0h3M12 3v3m0 15v3"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
opacity="0.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-8 h-8 text-white"
|
||||
>
|
||||
<path
|
||||
d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 22a2 2 0 01-2-2h4a2 2 0 01-2 2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M12 11v3m0-6v1"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const steps: Step[] = [
|
||||
{
|
||||
number: 1,
|
||||
title: "Enroll Your Identity",
|
||||
description:
|
||||
"Sign up and add your emails, phone numbers, and family members to create your protection profile.",
|
||||
icon: EnrollIcon,
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
title: "We Monitor 24/7",
|
||||
description:
|
||||
"Our system runs continuous dark web scans, voiceprint detection, and spam filtering to catch threats early.",
|
||||
icon: MonitorIcon,
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
title: "Get Instant Alerts",
|
||||
description:
|
||||
"Receive real-time notifications the moment a threat is detected, with clear guidance on what to do next.",
|
||||
icon: AlertIcon,
|
||||
},
|
||||
];
|
||||
|
||||
interface StepBlockProps {
|
||||
step: Step;
|
||||
index: number;
|
||||
}
|
||||
|
||||
function StepBlock(props: StepBlockProps) {
|
||||
const isEven = props.index % 2 === 0;
|
||||
const Icon = props.step.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
"flex gap-8 md:flex-row flex-col",
|
||||
isEven ? "" : "md:flex-row-reverse",
|
||||
)}
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-start gap-5">
|
||||
<div class="w-14 h-14 rounded-full gradient-primary shadow-glow-primary flex items-center justify-center shrink-0">
|
||||
<Icon />
|
||||
</div>
|
||||
<div>
|
||||
<div class="inline-flex items-center gap-2 mb-1.5">
|
||||
<span class="text-sm font-semibold text-[var(--color-brand-primary)]">
|
||||
Step {props.step.number}
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="text-xl md:text-2xl font-bold text-[var(--color-text-primary)] mb-2">
|
||||
{props.step.title}
|
||||
</h3>
|
||||
<p class="text-base text-[var(--color-text-secondary)] leading-relaxed">
|
||||
{props.step.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 hidden md:block" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface HowItWorksSectionProps {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export default function HowItWorksSection(props: HowItWorksSectionProps) {
|
||||
return (
|
||||
<section
|
||||
id="how-it-works"
|
||||
class={cn("py-20 md:py-28 scroll-mt-16", props.class)}
|
||||
>
|
||||
<PageContainer py="py-8">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
How It Works
|
||||
</h2>
|
||||
<p class="text-lg text-[var(--color-text-secondary)] max-w-2xl mx-auto">
|
||||
Three simple steps to full identity protection
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-12 md:gap-16">
|
||||
<For each={steps}>
|
||||
{(step, index) => <StepBlock step={step} index={index()} />}
|
||||
</For>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
import { For } from "solid-js";
|
||||
import type { JSX } from "solid-js";
|
||||
import { cn } from "~/lib/utils";
|
||||
import Card from "~/components/ui/Card";
|
||||
import PageContainer from "~/components/layout/PageContainer";
|
||||
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="flex-shrink-0"
|
||||
>
|
||||
<path
|
||||
d="M4 9l3 3 7-7"
|
||||
stroke="var(--color-success)"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ProactiveIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6 text-[var(--color-brand-primary)]"
|
||||
>
|
||||
<path
|
||||
d="M13 3l-2 6h5l-3 8"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4 14l5-5m0 0l5 5m-5-5v12"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AIIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6 text-[var(--color-brand-primary)]"
|
||||
>
|
||||
<path
|
||||
d="M12 3l1.5 4.5L18 9l-4.5 1.5L12 15l-1.5-4.5L6 9l4.5-1.5L12 3z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8 16l2 2-2 2M16 16l-2 2 2 2"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PrivacyIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6 text-[var(--color-brand-primary)]"
|
||||
>
|
||||
<path
|
||||
d="M12 2l9 4v6c0 5.55-3.84 10.74-9 12-5.16-1.26-9-6.45-9-12V6l9-4z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9 12l2 2 4-4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface ValueProp {
|
||||
title: string;
|
||||
description: string;
|
||||
items: string[];
|
||||
icon: () => JSX.Element;
|
||||
}
|
||||
|
||||
const valueProps: ValueProp[] = [
|
||||
{
|
||||
title: "Proactive, Not Reactive",
|
||||
description:
|
||||
"We detect threats before they cause damage, so you can act early.",
|
||||
icon: ProactiveIcon,
|
||||
items: [
|
||||
"Real-time dark web scanning",
|
||||
"Pre-breach alerts and warnings",
|
||||
"Automated threat response",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "AI-Powered Detection",
|
||||
description:
|
||||
"Machine learning models trained on real scam data to catch the latest threats.",
|
||||
icon: AIIcon,
|
||||
items: [
|
||||
"Deepfake voice identification",
|
||||
"Pattern-based scam detection",
|
||||
"Continuous model improvement",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Privacy First",
|
||||
description:
|
||||
"Your data stays encrypted and private. We never sell your information.",
|
||||
icon: PrivacyIcon,
|
||||
items: [
|
||||
"End-to-end encrypted data",
|
||||
"GDPR and CCPA compliant",
|
||||
"Zero data selling policy",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface ValueCardProps {
|
||||
prop: ValueProp;
|
||||
}
|
||||
|
||||
function ValueCard(props: ValueCardProps) {
|
||||
const Icon = props.prop.icon;
|
||||
return (
|
||||
<Card class="backdrop-blur-2xl">
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="mb-3 p-2 rounded-lg bg-[var(--color-bg-secondary)] w-fit">
|
||||
<Icon />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-[var(--color-text-primary)] mb-2">
|
||||
{props.prop.title}
|
||||
</h3>
|
||||
<p class="text-[var(--color-text-secondary)] mb-4 leading-relaxed">
|
||||
{props.prop.description}
|
||||
</p>
|
||||
<ul class="space-y-2 flex-1">
|
||||
<For each={props.prop.items}>
|
||||
{(item) => (
|
||||
<li class="flex items-start gap-2.5">
|
||||
<CheckIcon />
|
||||
<span class="text-[var(--color-text-secondary)] text-sm">
|
||||
{item}
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface WhyKordantSectionProps {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export default function WhyKordantSection(props: WhyKordantSectionProps) {
|
||||
return (
|
||||
<section
|
||||
id="why-kordant"
|
||||
class={cn("py-20 md:py-28 scroll-mt-16", props.class)}
|
||||
>
|
||||
<PageContainer py="py-8">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
Why Kordant
|
||||
</h2>
|
||||
<p class="text-lg text-[var(--color-text-secondary)] max-w-2xl mx-auto">
|
||||
Built on cutting-edge technology with your privacy at the core
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<For each={valueProps}>
|
||||
{(prop) => <ValueCard prop={prop} />}
|
||||
</For>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render } from "solid-js/web";
|
||||
import type { JSX } from "solid-js";
|
||||
|
||||
vi.mock("@solidjs/router", () => ({
|
||||
A: (props: { href?: string; children?: JSX.Element }) => {
|
||||
const href = props.href || "#";
|
||||
return (
|
||||
<a href={href}>
|
||||
{props.children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
import HeroSection from "./HeroSection";
|
||||
|
||||
function mount(comp: () => JSX.Element): HTMLDivElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
render(() => comp(), container);
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("HeroSection", () => {
|
||||
it("renders the headline with AI-Powered Identity Protection", () => {
|
||||
mount(() => <HeroSection />);
|
||||
expect(document.body.textContent).toContain("AI-Powered");
|
||||
expect(document.body.textContent).toContain("Identity Protection");
|
||||
expect(document.body.textContent).toContain("for Everyone");
|
||||
});
|
||||
|
||||
it("renders the subheadline", () => {
|
||||
mount(() => <HeroSection />);
|
||||
expect(document.body.textContent).toContain("Kordant uses advanced AI");
|
||||
});
|
||||
|
||||
it("renders the Get Started CTA", () => {
|
||||
mount(() => <HeroSection />);
|
||||
expect(document.body.textContent).toContain("Get Started");
|
||||
const primaryBtn = document.querySelector("button.gradient-primary");
|
||||
expect(primaryBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the Learn More CTA", () => {
|
||||
mount(() => <HeroSection />);
|
||||
expect(document.body.textContent).toContain("Learn More");
|
||||
});
|
||||
|
||||
it("renders trust indicators", () => {
|
||||
mount(() => <HeroSection />);
|
||||
expect(document.body.textContent).toContain("No credit card required");
|
||||
expect(document.body.textContent).toContain("Free tier available");
|
||||
});
|
||||
|
||||
it("renders the shield icon SVG", () => {
|
||||
mount(() => <HeroSection />);
|
||||
const svg = document.querySelector("svg");
|
||||
expect(svg).toBeTruthy();
|
||||
});
|
||||
|
||||
it("wraps content in PageContainer", () => {
|
||||
mount(() => <HeroSection />);
|
||||
const container = document.querySelector(".max-w-7xl");
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders two buttons for CTAs", () => {
|
||||
mount(() => <HeroSection />);
|
||||
const buttons = document.querySelectorAll("button");
|
||||
expect(buttons.length).toBe(2);
|
||||
});
|
||||
|
||||
it("has Get Started button wrapped in link to /signup", () => {
|
||||
mount(() => <HeroSection />);
|
||||
const links = document.querySelectorAll("a");
|
||||
const signupLink = Array.from(links).find(
|
||||
(a) => a.getAttribute("href") === "/signup",
|
||||
);
|
||||
expect(signupLink).toBeTruthy();
|
||||
expect(signupLink!.textContent).toContain("Get Started");
|
||||
});
|
||||
|
||||
it("has Learn More button wrapped in link to #features", () => {
|
||||
mount(() => <HeroSection />);
|
||||
const links = document.querySelectorAll("a");
|
||||
const featuresLink = Array.from(links).find(
|
||||
(a) => a.getAttribute("href") === "#features",
|
||||
);
|
||||
expect(featuresLink).toBeTruthy();
|
||||
expect(featuresLink!.textContent).toContain("Learn More");
|
||||
});
|
||||
|
||||
it("applies custom class prop", () => {
|
||||
mount(() => <HeroSection class="custom-hero" />);
|
||||
const section = document.querySelector("section.custom-hero");
|
||||
expect(section).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has centered text layout", () => {
|
||||
mount(() => <HeroSection />);
|
||||
const inner = document.querySelector(".text-center");
|
||||
expect(inner).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has responsive vertical padding", () => {
|
||||
mount(() => <HeroSection />);
|
||||
const inner = document.querySelector(".py-20");
|
||||
expect(inner).toBeTruthy();
|
||||
expect(inner!.className).toContain("md:py-32");
|
||||
});
|
||||
});
|
||||
@@ -1,379 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render } from "solid-js/web";
|
||||
import type { JSX } from "solid-js";
|
||||
|
||||
vi.mock("@solidjs/router", () => ({
|
||||
A: (props: { href?: string; children?: JSX.Element }) => {
|
||||
const href = props.href || "#";
|
||||
return (
|
||||
<a href={href}>
|
||||
{props.children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
import HowItWorksSection from "./HowItWorksSection";
|
||||
import FeaturesGridSection from "./FeaturesGridSection";
|
||||
import ForUsersSection from "./ForUsersSection";
|
||||
import WhyKordantSection from "./WhyKordantSection";
|
||||
import CTABannerSection from "./CTABannerSection";
|
||||
|
||||
function mount(comp: () => JSX.Element): HTMLDivElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
render(() => comp(), container);
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("HowItWorksSection", () => {
|
||||
it("renders the section heading", () => {
|
||||
mount(() => <HowItWorksSection />);
|
||||
expect(document.body.textContent).toContain("How It Works");
|
||||
});
|
||||
|
||||
it("renders the section subheading", () => {
|
||||
mount(() => <HowItWorksSection />);
|
||||
expect(document.body.textContent).toContain(
|
||||
"Three simple steps to full identity protection",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders all 3 steps", () => {
|
||||
mount(() => <HowItWorksSection />);
|
||||
expect(document.body.textContent).toContain("Enroll Your Identity");
|
||||
expect(document.body.textContent).toContain("We Monitor 24/7");
|
||||
expect(document.body.textContent).toContain("Get Instant Alerts");
|
||||
});
|
||||
|
||||
it("renders step descriptions", () => {
|
||||
mount(() => <HowItWorksSection />);
|
||||
expect(document.body.textContent).toContain(
|
||||
"Sign up and add your emails",
|
||||
);
|
||||
expect(document.body.textContent).toContain("dark web scans");
|
||||
expect(document.body.textContent).toContain("real-time notifications");
|
||||
});
|
||||
|
||||
it("renders 3 numbered circles with gradient-primary", () => {
|
||||
mount(() => <HowItWorksSection />);
|
||||
const circles = document.querySelectorAll(".gradient-primary");
|
||||
expect(circles.length).toBe(3);
|
||||
});
|
||||
|
||||
it("has the anchor ID for smooth scrolling", () => {
|
||||
mount(() => <HowItWorksSection />);
|
||||
const section = document.querySelector('#how-it-works');
|
||||
expect(section).toBeTruthy();
|
||||
});
|
||||
|
||||
it("applies custom class prop", () => {
|
||||
mount(() => <HowItWorksSection class="custom-how" />);
|
||||
const section = document.querySelector("section.custom-how");
|
||||
expect(section).toBeTruthy();
|
||||
});
|
||||
|
||||
it("wraps content in PageContainer", () => {
|
||||
mount(() => <HowItWorksSection />);
|
||||
const container = document.querySelector(".max-w-7xl");
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FeaturesGridSection", () => {
|
||||
it("renders the section heading", () => {
|
||||
mount(() => <FeaturesGridSection />);
|
||||
expect(document.body.textContent).toContain("Platform Features");
|
||||
});
|
||||
|
||||
it("renders the section subheading", () => {
|
||||
mount(() => <FeaturesGridSection />);
|
||||
expect(document.body.textContent).toContain("Comprehensive protection");
|
||||
});
|
||||
|
||||
it("renders all 6 feature cards", () => {
|
||||
mount(() => <FeaturesGridSection />);
|
||||
expect(document.body.textContent).toContain("DarkWatch");
|
||||
expect(document.body.textContent).toContain("VoicePrint");
|
||||
expect(document.body.textContent).toContain("SpamShield");
|
||||
expect(document.body.textContent).toContain("HomeTitle");
|
||||
expect(document.body.textContent).toContain("RemoveBrokers");
|
||||
expect(document.body.textContent).toContain("Family Plans");
|
||||
});
|
||||
|
||||
it("renders 6 Card components", () => {
|
||||
mount(() => <FeaturesGridSection />);
|
||||
const cards = document.querySelectorAll(".gradient-card");
|
||||
expect(cards.length).toBe(6);
|
||||
});
|
||||
|
||||
it("renders feature descriptions", () => {
|
||||
mount(() => <FeaturesGridSection />);
|
||||
expect(document.body.textContent).toContain("dark web monitoring");
|
||||
expect(document.body.textContent).toContain("voice clone detection");
|
||||
expect(document.body.textContent).toContain("scam call blocking");
|
||||
});
|
||||
|
||||
it("has the anchor ID for smooth scrolling", () => {
|
||||
mount(() => <FeaturesGridSection />);
|
||||
const section = document.querySelector('#features');
|
||||
expect(section).toBeTruthy();
|
||||
});
|
||||
|
||||
it("applies custom class prop", () => {
|
||||
mount(() => <FeaturesGridSection class="custom-features" />);
|
||||
const section = document.querySelector("section.custom-features");
|
||||
expect(section).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses responsive grid layout", () => {
|
||||
mount(() => <FeaturesGridSection />);
|
||||
const grid = document.querySelector(".grid-cols-1");
|
||||
expect(grid).toBeTruthy();
|
||||
expect(grid!.className).toContain("md:grid-cols-2");
|
||||
expect(grid!.className).toContain("lg:grid-cols-3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ForUsersSection", () => {
|
||||
it("renders the section heading", () => {
|
||||
mount(() => <ForUsersSection />);
|
||||
expect(document.body.textContent).toContain("For Everyone");
|
||||
});
|
||||
|
||||
it("renders the section subheading", () => {
|
||||
mount(() => <ForUsersSection />);
|
||||
expect(document.body.textContent).toContain(
|
||||
"Whether you're protecting yourself",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders both panels", () => {
|
||||
mount(() => <ForUsersSection />);
|
||||
expect(document.body.textContent).toContain("For Individuals");
|
||||
expect(document.body.textContent).toContain("For Families");
|
||||
});
|
||||
|
||||
it("renders individual panel description", () => {
|
||||
mount(() => <ForUsersSection />);
|
||||
expect(document.body.textContent).toContain(
|
||||
"Personal identity protection",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders family panel description", () => {
|
||||
mount(() => <ForUsersSection />);
|
||||
expect(document.body.textContent).toContain("Group management tools");
|
||||
});
|
||||
|
||||
it("renders bullet items for individuals", () => {
|
||||
mount(() => <ForUsersSection />);
|
||||
expect(document.body.textContent).toContain(
|
||||
"Monitor personal email and phone numbers",
|
||||
);
|
||||
expect(document.body.textContent).toContain(
|
||||
"Dark web credential scanning",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders bullet items for families", () => {
|
||||
mount(() => <ForUsersSection />);
|
||||
expect(document.body.textContent).toContain(
|
||||
"Add unlimited family members",
|
||||
);
|
||||
expect(document.body.textContent).toContain("Shared alert dashboard");
|
||||
});
|
||||
|
||||
it("renders checkmark icons", () => {
|
||||
mount(() => <ForUsersSection />);
|
||||
const checkmarks = document.querySelectorAll(
|
||||
'svg path[fill="var(--color-success)"]',
|
||||
);
|
||||
expect(checkmarks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders 2 Card components for panels", () => {
|
||||
mount(() => <ForUsersSection />);
|
||||
const cards = document.querySelectorAll(".gradient-card");
|
||||
expect(cards.length).toBe(2);
|
||||
});
|
||||
|
||||
it("has the anchor ID for smooth scrolling", () => {
|
||||
mount(() => <ForUsersSection />);
|
||||
const section = document.querySelector('#for-users');
|
||||
expect(section).toBeTruthy();
|
||||
});
|
||||
|
||||
it("applies custom class prop", () => {
|
||||
mount(() => <ForUsersSection class="custom-users" />);
|
||||
const section = document.querySelector("section.custom-users");
|
||||
expect(section).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses two-column grid on desktop", () => {
|
||||
mount(() => <ForUsersSection />);
|
||||
const grid = document.querySelector(".grid-cols-1");
|
||||
expect(grid).toBeTruthy();
|
||||
expect(grid!.className).toContain("md:grid-cols-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("WhyKordantSection", () => {
|
||||
it("renders the section heading", () => {
|
||||
mount(() => <WhyKordantSection />);
|
||||
expect(document.body.textContent).toContain("Why Kordant");
|
||||
});
|
||||
|
||||
it("renders the section subheading", () => {
|
||||
mount(() => <WhyKordantSection />);
|
||||
expect(document.body.textContent).toContain(
|
||||
"Built on cutting-edge technology",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders all 3 value prop cards", () => {
|
||||
mount(() => <WhyKordantSection />);
|
||||
expect(document.body.textContent).toContain("Proactive, Not Reactive");
|
||||
expect(document.body.textContent).toContain("AI-Powered Detection");
|
||||
expect(document.body.textContent).toContain("Privacy First");
|
||||
});
|
||||
|
||||
it("renders value prop descriptions", () => {
|
||||
mount(() => <WhyKordantSection />);
|
||||
expect(document.body.textContent).toContain(
|
||||
"detect threats before they cause damage",
|
||||
);
|
||||
expect(document.body.textContent).toContain(
|
||||
"Machine learning models trained",
|
||||
);
|
||||
expect(document.body.textContent).toContain("encrypted and private");
|
||||
});
|
||||
|
||||
it("renders bullet items for each card", () => {
|
||||
mount(() => <WhyKordantSection />);
|
||||
expect(document.body.textContent).toContain(
|
||||
"Real-time dark web scanning",
|
||||
);
|
||||
expect(document.body.textContent).toContain(
|
||||
"Deepfake voice identification",
|
||||
);
|
||||
expect(document.body.textContent).toContain("End-to-end encrypted data");
|
||||
});
|
||||
|
||||
it("renders 3 Card components", () => {
|
||||
mount(() => <WhyKordantSection />);
|
||||
const cards = document.querySelectorAll(".gradient-card");
|
||||
expect(cards.length).toBe(3);
|
||||
});
|
||||
|
||||
it("has the anchor ID for smooth scrolling", () => {
|
||||
mount(() => <WhyKordantSection />);
|
||||
const section = document.querySelector('#why-kordant');
|
||||
expect(section).toBeTruthy();
|
||||
});
|
||||
|
||||
it("applies custom class prop", () => {
|
||||
mount(() => <WhyKordantSection class="custom-why" />);
|
||||
const section = document.querySelector("section.custom-why");
|
||||
expect(section).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses three-column grid on desktop", () => {
|
||||
mount(() => <WhyKordantSection />);
|
||||
const grid = document.querySelector(".grid-cols-1");
|
||||
expect(grid).toBeTruthy();
|
||||
expect(grid!.className).toContain("md:grid-cols-3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CTABannerSection", () => {
|
||||
it("renders the CTA headline", () => {
|
||||
mount(() => <CTABannerSection />);
|
||||
expect(document.body.textContent).toContain(
|
||||
"Ready to protect your identity?",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the CTA subtext", () => {
|
||||
mount(() => <CTABannerSection />);
|
||||
expect(document.body.textContent).toContain(
|
||||
"Join thousands of users",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders Create Account button", () => {
|
||||
mount(() => <CTABannerSection />);
|
||||
expect(document.body.textContent).toContain("Create Account");
|
||||
const primaryBtn = document.querySelector("button.gradient-primary");
|
||||
expect(primaryBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders Sign In button", () => {
|
||||
mount(() => <CTABannerSection />);
|
||||
expect(document.body.textContent).toContain("Sign In");
|
||||
});
|
||||
|
||||
it("has Create Account link to /signup", () => {
|
||||
mount(() => <CTABannerSection />);
|
||||
const links = document.querySelectorAll("a");
|
||||
const signupLink = Array.from(links).find(
|
||||
(a) => a.getAttribute("href") === "/signup",
|
||||
);
|
||||
expect(signupLink).toBeTruthy();
|
||||
expect(signupLink!.textContent).toContain("Create Account");
|
||||
});
|
||||
|
||||
it("has Sign In link to /login", () => {
|
||||
mount(() => <CTABannerSection />);
|
||||
const links = document.querySelectorAll("a");
|
||||
const loginLink = Array.from(links).find(
|
||||
(a) => a.getAttribute("href") === "/login",
|
||||
);
|
||||
expect(loginLink).toBeTruthy();
|
||||
expect(loginLink!.textContent).toContain("Sign In");
|
||||
});
|
||||
|
||||
it("renders 2 buttons", () => {
|
||||
mount(() => <CTABannerSection />);
|
||||
const buttons = document.querySelectorAll("button");
|
||||
expect(buttons.length).toBe(2);
|
||||
});
|
||||
|
||||
it("has the anchor ID for smooth scrolling", () => {
|
||||
mount(() => <CTABannerSection />);
|
||||
const section = document.querySelector('#cta');
|
||||
expect(section).toBeTruthy();
|
||||
});
|
||||
|
||||
it("applies custom class prop", () => {
|
||||
mount(() => <CTABannerSection class="custom-cta" />);
|
||||
const section = document.querySelector("section.custom-cta");
|
||||
expect(section).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses centered text layout", () => {
|
||||
mount(() => <CTABannerSection />);
|
||||
const inner = document.querySelector(".text-center");
|
||||
expect(inner).toBeTruthy();
|
||||
});
|
||||
|
||||
it("wraps content in PageContainer", () => {
|
||||
mount(() => <CTABannerSection />);
|
||||
const container = document.querySelector(".max-w-7xl");
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses gradient card for CTA banner", () => {
|
||||
mount(() => <CTABannerSection />);
|
||||
const card = document.querySelector(".gradient-card");
|
||||
expect(card).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -143,7 +143,7 @@ function RealtimeIndicator() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearUnread}
|
||||
class="relative flex items-center justify-center w-6 h-6 rounded-full bg-[var(--color-error)] text-white text-[10px] font-bold leading-none transition-transform hover:scale-110"
|
||||
class="relative flex items-center justify-center w-6 h-6 rounded-full bg-(--color-error) text-white text-[10px] font-bold leading-none transition-transform hover:scale-110"
|
||||
aria-label={`${unreadCount()} unread alerts`}
|
||||
>
|
||||
{unreadCount() > 99 ? "99+" : unreadCount()}
|
||||
@@ -152,17 +152,24 @@ function RealtimeIndicator() {
|
||||
<Show
|
||||
when={connectionStatus() === "connected"}
|
||||
fallback={
|
||||
connectionStatus() === "reconnecting" || connectionStatus() === "connecting" ? (
|
||||
<div class="flex items-center gap-1 text-[10px] text-[var(--color-warning)]" aria-label="Reconnecting">
|
||||
connectionStatus() === "reconnecting" ||
|
||||
connectionStatus() === "connecting" ? (
|
||||
<div
|
||||
class="flex items-center gap-1 text-[10px] text-(--color-warning)"
|
||||
aria-label="Reconnecting"
|
||||
>
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-[var(--color-warning)] opacity-75" />
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-[var(--color-warning)]" />
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-(--color-warning) opacity-75" />
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-(--color-warning)" />
|
||||
</span>
|
||||
<span class="hidden sm:inline">Reconnecting</span>
|
||||
</div>
|
||||
) : (
|
||||
<div class="flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]" aria-label="Offline">
|
||||
<span class="inline-flex rounded-full h-2 w-2 bg-[var(--color-text-muted)]" />
|
||||
<div
|
||||
class="flex items-center gap-1 text-[10px] text-(--color-text-muted)"
|
||||
aria-label="Offline"
|
||||
>
|
||||
<span class="inline-flex rounded-full h-2 w-2 bg-(--color-text-muted)" />
|
||||
<span class="hidden sm:inline">Offline</span>
|
||||
</div>
|
||||
)
|
||||
@@ -170,8 +177,8 @@ function RealtimeIndicator() {
|
||||
>
|
||||
<div class="flex items-center gap-1" aria-label="Connected">
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-[var(--color-success)] opacity-75" />
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-[var(--color-success)]" />
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-(--color-success) opacity-75" />
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-(--color-success)" />
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -197,7 +204,11 @@ export default function Navbar() {
|
||||
return location.pathname.startsWith(href);
|
||||
};
|
||||
|
||||
const NavLink = (props: { href: string; label: string; mobile?: boolean }) => (
|
||||
const NavLink = (props: {
|
||||
href: string;
|
||||
label: string;
|
||||
mobile?: boolean;
|
||||
}) => (
|
||||
<A
|
||||
href={props.href}
|
||||
class={cn(
|
||||
@@ -205,9 +216,11 @@ export default function Navbar() {
|
||||
? "block px-3 py-2 rounded-lg text-base font-medium transition-colors"
|
||||
: "text-sm font-medium transition-colors",
|
||||
isActive(props.href)
|
||||
? "text-[var(--color-text-primary)]"
|
||||
? "text-text-primary"
|
||||
: "text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]",
|
||||
props.mobile && !isActive(props.href) && "hover:bg-[var(--color-bg-secondary)]",
|
||||
props.mobile &&
|
||||
!isActive(props.href) &&
|
||||
"hover:bg-[var(--color-bg-secondary)]",
|
||||
)}
|
||||
onClick={() => props.mobile && setMobileOpen(false)}
|
||||
>
|
||||
@@ -234,10 +247,14 @@ export default function Navbar() {
|
||||
|
||||
<div class="hidden md:flex items-center gap-6">
|
||||
<SignedOut>
|
||||
{marketingLinks.map(link => <NavLink href={link.href} label={link.label} />)}
|
||||
{marketingLinks.map((link) => (
|
||||
<NavLink href={link.href} label={link.label} />
|
||||
))}
|
||||
</SignedOut>
|
||||
<SignedIn>
|
||||
{productLinks.map(link => <NavLink href={link.href} label={link.label} />)}
|
||||
{productLinks.map((link) => (
|
||||
<NavLink href={link.href} label={link.label} />
|
||||
))}
|
||||
</SignedIn>
|
||||
</div>
|
||||
|
||||
@@ -263,7 +280,7 @@ export default function Navbar() {
|
||||
type="button"
|
||||
aria-label="Toggle menu"
|
||||
class="p-2 rounded-lg text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]"
|
||||
onClick={() => setMobileOpen(v => !v)}
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
>
|
||||
<Show
|
||||
when={mobileOpen()}
|
||||
@@ -304,12 +321,12 @@ export default function Navbar() {
|
||||
<div class="md:hidden glass border-t border-[var(--color-border)]">
|
||||
<div class="px-4 py-4 space-y-1">
|
||||
<SignedOut>
|
||||
{marketingLinks.map(link => (
|
||||
{marketingLinks.map((link) => (
|
||||
<NavLink href={link.href} label={link.label} mobile />
|
||||
))}
|
||||
</SignedOut>
|
||||
<SignedIn>
|
||||
{productLinks.map(link => (
|
||||
{productLinks.map((link) => (
|
||||
<NavLink href={link.href} label={link.label} mobile />
|
||||
))}
|
||||
</SignedIn>
|
||||
|
||||
@@ -4,29 +4,20 @@ export interface OnboardingData {
|
||||
familyInvites: string[];
|
||||
}
|
||||
|
||||
export function getCheckoutUrl(plan: string): string | null {
|
||||
const prices: Record<string, string | undefined> = {
|
||||
basic: process.env.STRIPE_PRICE_BASIC,
|
||||
plus: process.env.STRIPE_PRICE_PLUS,
|
||||
premium: process.env.STRIPE_PRICE_PREMIUM,
|
||||
};
|
||||
const priceId = prices[plan];
|
||||
if (!priceId) return null;
|
||||
return `/billing/checkout?priceId=${priceId}`;
|
||||
}
|
||||
|
||||
export async function submitOnboarding(data: OnboardingData) {
|
||||
const { api } = await import("~/lib/api");
|
||||
|
||||
if (data.plan) {
|
||||
try {
|
||||
const prices: Record<string, string | undefined> = {
|
||||
basic: process.env.STRIPE_PRICE_BASIC,
|
||||
plus: process.env.STRIPE_PRICE_PLUS,
|
||||
premium: process.env.STRIPE_PRICE_PREMIUM,
|
||||
};
|
||||
const priceId = prices[data.plan];
|
||||
if (priceId) {
|
||||
await api.billing.createCheckoutSession.mutate({
|
||||
priceId,
|
||||
successUrl: `${window.location.origin}/dashboard`,
|
||||
cancelUrl: `${window.location.origin}/onboarding`,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// billing setup not required for free plan
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of data.watchlistItems) {
|
||||
const type = item.includes("@") ? "EMAIL" : "PHONE";
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createSignal, For, Show } from "solid-js";
|
||||
import { Title } from "@solidjs/meta";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { Button, Input, Badge } from "~/components/ui";
|
||||
import { submitOnboarding } from "~/lib/auth";
|
||||
import { submitOnboarding, getCheckoutUrl } from "~/lib/auth";
|
||||
import type { OnboardingData } from "~/lib/auth";
|
||||
|
||||
const plans = [
|
||||
@@ -86,6 +86,12 @@ export default function OnboardingPage() {
|
||||
}
|
||||
|
||||
async function completeOnboarding() {
|
||||
const checkoutUrl = getCheckoutUrl(plan());
|
||||
if (checkoutUrl) {
|
||||
navigate(checkoutUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const data: OnboardingData = {
|
||||
@@ -392,23 +398,12 @@ export default function OnboardingPage() {
|
||||
selected items and will alert you of any threats.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 w-full max-w-xs">
|
||||
<Button
|
||||
onClick={() => navigate("/dashboard", { replace: true })}
|
||||
class="w-full"
|
||||
>
|
||||
Go to Dashboard
|
||||
</Button>
|
||||
{!submitting && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={completeOnboarding}
|
||||
class="w-full"
|
||||
>
|
||||
Submit onboarding data
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => navigate("/dashboard", { replace: true })}
|
||||
class="w-full max-w-xs"
|
||||
>
|
||||
Go to Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
34
web/src/routes/api/stripe/session-status.ts
Normal file
34
web/src/routes/api/stripe/session-status.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { stripe } from "~/server/stripe";
|
||||
|
||||
export async function GET(event: APIEvent) {
|
||||
const url = new URL(event.request.url);
|
||||
const sessionId = url.searchParams.get("session_id");
|
||||
|
||||
if (!sessionId) {
|
||||
return new Response(JSON.stringify({ error: "Missing session_id" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await stripe.checkout.sessions.retrieve(sessionId, {
|
||||
expand: ["customer_details.email"],
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
status: session.status,
|
||||
customer_email: typeof session.customer_details === "string"
|
||||
? undefined
|
||||
: session.customer_details?.email,
|
||||
}), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: "Failed to retrieve session" }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
89
web/src/routes/billing/checkout.tsx
Normal file
89
web/src/routes/billing/checkout.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { createSignal, onMount, Show } from "solid-js";
|
||||
import { Title } from "@solidjs/meta";
|
||||
import { useNavigate, useSearchParams } from "@solidjs/router";
|
||||
import EmbeddedCheckout from "~/components/EmbeddedCheckout";
|
||||
import { api } from "~/lib/api";
|
||||
import PageContainer from "~/components/layout/PageContainer";
|
||||
|
||||
const priceMap: Record<string, string> = {
|
||||
basic: process.env.STRIPE_PRICE_BASIC ?? "",
|
||||
plus: process.env.STRIPE_PRICE_PLUS ?? "",
|
||||
premium: process.env.STRIPE_PRICE_PREMIUM ?? "",
|
||||
};
|
||||
|
||||
export default function CheckoutPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [clientSecret, setClientSecret] = createSignal("");
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
|
||||
onMount(async () => {
|
||||
const plan = Array.isArray(searchParams.plan) ? searchParams.plan[0] : searchParams.plan;
|
||||
const priceIdParam = Array.isArray(searchParams.priceId) ? searchParams.priceId[0] : searchParams.priceId;
|
||||
const priceId = priceIdParam ?? (plan ? priceMap[plan] : "");
|
||||
|
||||
if (!priceId) {
|
||||
setError("No plan selected. Please select a plan to continue.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const returnUrl = `${window.location.origin}/billing/return`;
|
||||
const result = await api.billing.createCheckoutSession.mutate({
|
||||
priceId,
|
||||
returnUrl,
|
||||
});
|
||||
setClientSecret(result.clientSecret);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create checkout session");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<main class="min-h-screen py-8 md:py-12">
|
||||
<Title>Checkout — Kordant</Title>
|
||||
<PageContainer>
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-[var(--color-text-primary)]">Complete your purchase</h1>
|
||||
<p class="text-sm text-[var(--color-text-secondary)] mt-1">
|
||||
Secure payment powered by Stripe
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Show when={loading()}>
|
||||
<div class="flex items-center justify-center min-h-[300px]">
|
||||
<div class="text-center">
|
||||
<div class="animate-spin h-8 w-8 border-2 border-[var(--color-brand-primary)] border-t-transparent rounded-full mx-auto mb-3" />
|
||||
<p class="text-sm text-[var(--color-text-secondary)]">Preparing checkout...</p>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={error() && !loading()}>
|
||||
<div class="text-center py-8 border border-[var(--color-border)] rounded-xl">
|
||||
<p class="text-[var(--color-error)] mb-4">{error()}</p>
|
||||
<button
|
||||
onClick={() => navigate("/pricing")}
|
||||
class="text-sm text-[var(--color-brand-primary)] hover:underline"
|
||||
>
|
||||
← Back to pricing
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={clientSecret() && !loading() && !error()}>
|
||||
<EmbeddedCheckout
|
||||
clientSecret={clientSecret()}
|
||||
onCheckoutComplete={() => navigate("/dashboard")}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
131
web/src/routes/billing/return.tsx
Normal file
131
web/src/routes/billing/return.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { createSignal, onMount, Show } from "solid-js";
|
||||
import { Title } from "@solidjs/meta";
|
||||
import { useNavigate, useSearchParams } from "@solidjs/router";
|
||||
import PageContainer from "~/components/layout/PageContainer";
|
||||
|
||||
export default function ReturnPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [status, setStatus] = createSignal<"loading" | "complete" | "open" | "error">("loading");
|
||||
const [customerEmail, setCustomerEmail] = createSignal("");
|
||||
|
||||
onMount(async () => {
|
||||
const sessionId = Array.isArray(searchParams.session_id)
|
||||
? searchParams.session_id[0]
|
||||
: searchParams.session_id;
|
||||
|
||||
if (!sessionId) {
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/stripe/session-status?session_id=${sessionId}`);
|
||||
if (!response.ok) throw new Error("Failed to check session status");
|
||||
const data = await response.json();
|
||||
setStatus(data.status);
|
||||
setCustomerEmail(data.customer_email ?? "");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
});
|
||||
|
||||
const isComplete = status() === "complete";
|
||||
const isOpen = status() === "open";
|
||||
const isError = status() === "error";
|
||||
|
||||
return (
|
||||
<main class="min-h-screen py-8 md:py-12">
|
||||
<Title>Payment Status — Kordant</Title>
|
||||
<PageContainer>
|
||||
<div class="max-w-xl mx-auto text-center">
|
||||
<Show when={status() === "loading"}>
|
||||
<div class="flex items-center justify-center min-h-[300px]">
|
||||
<div class="text-center">
|
||||
<div class="animate-spin h-8 w-8 border-2 border-[var(--color-brand-primary)] border-t-transparent rounded-full mx-auto mb-3" />
|
||||
<p class="text-sm text-[var(--color-text-secondary)]">Checking payment status...</p>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={isOpen}>
|
||||
<div class="py-8">
|
||||
<h1 class="text-2xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
Payment in progress
|
||||
</h1>
|
||||
<p class="text-[var(--color-text-secondary)] mb-6">
|
||||
Your payment session is still open. Please complete the checkout process.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate("/billing/checkout")}
|
||||
class="text-sm text-[var(--color-brand-primary)] hover:underline"
|
||||
>
|
||||
← Return to checkout
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={isComplete}>
|
||||
<div class="py-8">
|
||||
<div class="h-16 w-16 rounded-full gradient-primary flex items-center justify-center mx-auto mb-6">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8 text-white"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M20 6L9 17L4 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
Payment successful!
|
||||
</h1>
|
||||
<p class="text-[var(--color-text-secondary)] mb-2">
|
||||
We appreciate your business!
|
||||
</p>
|
||||
<Show when={customerEmail()}>
|
||||
<p class="text-sm text-[var(--color-text-tertiary)] mb-6">
|
||||
A confirmation email will be sent to {customerEmail()}.
|
||||
</p>
|
||||
</Show>
|
||||
<button
|
||||
onClick={() => navigate("/dashboard", { replace: true })}
|
||||
class="px-6 py-3 rounded-lg gradient-primary text-white font-medium hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Go to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={isError}>
|
||||
<div class="py-8">
|
||||
<h1 class="text-2xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
Something went wrong
|
||||
</h1>
|
||||
<p class="text-[var(--color-text-secondary)] mb-6">
|
||||
We couldn't verify your payment status. Please try again.
|
||||
</p>
|
||||
<div class="flex gap-3 justify-center">
|
||||
<button
|
||||
onClick={() => navigate("/billing/checkout")}
|
||||
class="text-sm text-[var(--color-brand-primary)] hover:underline"
|
||||
>
|
||||
Try checkout again
|
||||
</button>
|
||||
<span class="text-[var(--color-text-tertiary)]">or</span>
|
||||
<button
|
||||
onClick={() => navigate("/pricing")}
|
||||
class="text-sm text-[var(--color-brand-primary)] hover:underline"
|
||||
>
|
||||
View plans
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export default function BlogPage() {
|
||||
|
||||
// Fetch all published posts
|
||||
const allPosts = createMemo(() => {
|
||||
return api.blog.list.query({ limit: "100" }).then(res => {
|
||||
return api.blog.list.query({ limit: "100" }).then((res) => {
|
||||
setLoading(false);
|
||||
return res.posts;
|
||||
});
|
||||
@@ -32,9 +32,9 @@ export default function BlogPage() {
|
||||
|
||||
// Fetch featured post
|
||||
const featuredPost = createMemo(() => {
|
||||
return api.blog.list.query({ limit: "100" }).then(res =>
|
||||
res.posts.find((p: any) => p.featured) ?? null
|
||||
);
|
||||
return api.blog.list
|
||||
.query({ limit: "100" })
|
||||
.then((res) => res.posts.find((p: any) => p.featured) ?? null);
|
||||
});
|
||||
|
||||
// Filtered + visible posts
|
||||
@@ -51,8 +51,8 @@ export default function BlogPage() {
|
||||
return filtered.slice(0, visibleCount());
|
||||
});
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
const posts = allPosts();
|
||||
const filtered = createMemo(async () => {
|
||||
const posts = await allPosts();
|
||||
if (!posts) return [];
|
||||
const tag = selectedTag();
|
||||
if (!tag) return posts;
|
||||
@@ -77,7 +77,8 @@ export default function BlogPage() {
|
||||
Kordant Blog
|
||||
</h1>
|
||||
<p class="text-lg text-[var(--color-text-secondary)] max-w-2xl mx-auto">
|
||||
Insights on identity protection, AI safety, and the latest digital threats
|
||||
Insights on identity protection, AI safety, and the latest digital
|
||||
threats
|
||||
</p>
|
||||
</div>
|
||||
</PageContainer>
|
||||
@@ -92,19 +93,37 @@ export default function BlogPage() {
|
||||
<A href={`/blog/${fp().slug}`}>
|
||||
<Card class="flex flex-col md:flex-row gap-6 p-6 hover:shadow-lg transition-shadow">
|
||||
<div class="md:w-64 h-40 md:h-auto bg-gradient-to-br from-[var(--color-brand-primary)]/20 to-[var(--color-brand-accent)]/20 rounded-lg flex-shrink-0 flex items-center justify-center">
|
||||
<svg width="40" height="40" viewBox="0 0 32 32" fill="none" class="text-[var(--color-brand-primary)] opacity-40">
|
||||
<path d="M14 2L2 8v8c0 7 6 12 12 13 6-1 12-6 12-13V8L14 2z" stroke="currentColor" stroke-width="2"/>
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
class="text-[var(--color-brand-primary)] opacity-40"
|
||||
>
|
||||
<path
|
||||
d="M14 2L2 8v8c0 7 6 12 12 13 6-1 12-6 12-13V8L14 2z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Badge variant="info" class="text-xs">Featured</Badge>
|
||||
<Badge variant="info" class="text-xs">
|
||||
Featured
|
||||
</Badge>
|
||||
<span class="text-xs text-[var(--color-text-tertiary)]">
|
||||
{fp().publishedAt ? new Date(fp().publishedAt).toLocaleDateString() : ""}
|
||||
{fp().publishedAt
|
||||
? new Date(fp().publishedAt).toLocaleDateString()
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="text-xl font-bold text-[var(--color-text-primary)] mb-2">{fp().title}</h2>
|
||||
<p class="text-sm text-[var(--color-text-secondary)] line-clamp-2">{fp().excerpt}</p>
|
||||
<h2 class="text-xl font-bold text-[var(--color-text-primary)] mb-2">
|
||||
{fp().title}
|
||||
</h2>
|
||||
<p class="text-sm text-[var(--color-text-secondary)] line-clamp-2">
|
||||
{fp().excerpt}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</A>
|
||||
@@ -121,7 +140,10 @@ export default function BlogPage() {
|
||||
<div class="flex flex-wrap items-center gap-2 mb-10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSelectedTag(null); setVisibleCount(POSTS_PER_PAGE); }}
|
||||
onClick={() => {
|
||||
setSelectedTag(null);
|
||||
setVisibleCount(POSTS_PER_PAGE);
|
||||
}}
|
||||
class={cn(
|
||||
"px-3 py-1.5 rounded-full text-sm font-medium transition-colors",
|
||||
!selectedTag()
|
||||
@@ -135,7 +157,10 @@ export default function BlogPage() {
|
||||
{({ tag, count }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSelectedTag(tag); setVisibleCount(POSTS_PER_PAGE); }}
|
||||
onClick={() => {
|
||||
setSelectedTag(tag);
|
||||
setVisibleCount(POSTS_PER_PAGE);
|
||||
}}
|
||||
class={cn(
|
||||
"px-3 py-1.5 rounded-full text-sm font-medium transition-colors",
|
||||
selectedTag() === tag
|
||||
@@ -157,9 +182,27 @@ export default function BlogPage() {
|
||||
<A href={`/blog/${post.slug}`}>
|
||||
<Card class="h-full hover:shadow-lg transition-shadow duration-300">
|
||||
<div class="h-40 bg-gradient-to-br from-[var(--color-brand-primary)]/20 to-[var(--color-brand-accent)]/20 rounded-lg mb-4 flex items-center justify-center text-[var(--color-text-tertiary)]">
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" class="opacity-40">
|
||||
<rect x="4" y="6" width="24" height="20" rx="2" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M4 12h24M12 6v20" stroke="currentColor" stroke-width="2"/>
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
class="opacity-40"
|
||||
>
|
||||
<rect
|
||||
x="4"
|
||||
y="6"
|
||||
width="24"
|
||||
height="20"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
d="M4 12h24M12 6v20"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5 mb-3">
|
||||
@@ -176,7 +219,9 @@ export default function BlogPage() {
|
||||
<div class="flex items-center justify-between text-xs text-[var(--color-text-tertiary)] mt-auto">
|
||||
<span>{post.authorName || "Kordant"}</span>
|
||||
<span>
|
||||
{post.publishedAt ? new Date(post.publishedAt).toLocaleDateString() : ""}
|
||||
{post.publishedAt
|
||||
? new Date(post.publishedAt).toLocaleDateString()
|
||||
: ""}
|
||||
{" · "}
|
||||
{readingTime(post.content)}
|
||||
</span>
|
||||
@@ -189,7 +234,9 @@ export default function BlogPage() {
|
||||
|
||||
<Show when={visible().length === 0}>
|
||||
<div class="text-center py-16">
|
||||
<p class="text-[var(--color-text-secondary)] text-lg">No posts found{selectedTag() ? ` for "${selectedTag()}"` : ""}</p>
|
||||
<p class="text-[var(--color-text-secondary)] text-lg">
|
||||
No posts found{selectedTag() ? ` for "${selectedTag()}"` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
|
||||
@@ -228,73 +228,74 @@ export default function Home() {
|
||||
});
|
||||
|
||||
return (
|
||||
<main class="overflow-hidden" style="--cut: clamp(16px, 2.5vw, 40px)">
|
||||
<div style="--cut: clamp(16px, 2.5vw, 40px)">
|
||||
<Title>Kordant — AI-Powered Identity Protection</Title>
|
||||
|
||||
{/* Hero */}
|
||||
{/* Fixed background — stays locked while content scrolls over it */}
|
||||
<ColorWaveBackground yOffset={-0.1} scale={0.65} speed={0.5} />
|
||||
<div class="relative z-10">
|
||||
<section>
|
||||
<PageContainer>
|
||||
<div
|
||||
ref={heroRef}
|
||||
class="flex flex-col items-center text-center py-20 md:py-32 transition-all duration-700 ease-out"
|
||||
style="opacity: 0; transform: translateY(20px);"
|
||||
>
|
||||
<div class="mb-6 shadow-glow-primary rounded-full p-3">
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg" class="w-12 h-12 md:w-16 md:h-16">
|
||||
<circle cx="24" cy="24" r="24" fill="var(--color-brand-primary)" />
|
||||
<path d="M24 10L16 14v6.5c0 5.1 3.4 9.9 8 11.5 4.6-1.6 8-6.4 8-11.5V14l-8-4z" fill="white" fill-opacity="0.9" />
|
||||
<path d="M20 24l3 2.5 5-5" stroke="var(--color-brand-primary)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 class="text-4xl md:text-6xl lg:text-7xl font-bold tracking-tight mb-6 max-w-4xl">
|
||||
<Typewriter speed={50} delay={400} keepAlive={false}>
|
||||
<span class="text-text-primary">AI-Powered </span>
|
||||
<span class="text-gradient-primary">Identity Protection</span>
|
||||
<br />
|
||||
<span class="text-text-primary">for Everyone</span>
|
||||
</Typewriter>
|
||||
</h1>
|
||||
|
||||
<p class="text-xl md:text-2xl text-text-secondary max-w-2xl mb-10 leading-relaxed">
|
||||
Threat actors are using AI in multifaceted attacks. Kordant evens
|
||||
the playing field using advanced AI to monitor, detect, and prevent
|
||||
identity threats in real-time.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 mb-8">
|
||||
<A href="/signup">
|
||||
<Button variant="primary" size="lg">Get Started</Button>
|
||||
</A>
|
||||
<A href="#features">
|
||||
<Button variant="ghost" size="lg">Learn More</Button>
|
||||
</A>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-sm text-text-tertiary">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M6.5 11.5L3 8L4.1 6.9L6.5 9.3L12.4 3.4L13.5 4.5L6.5 11.5Z" fill="var(--color-success)" /></svg>
|
||||
No credit card required
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M6.5 11.5L3 8L4.1 6.9L6.5 9.3L12.4 3.4L13.5 4.5L6.5 11.5Z" fill="var(--color-success)" /></svg>
|
||||
Free tier available
|
||||
</span>
|
||||
</div>
|
||||
{/* Hero */}
|
||||
<section class="relative z-10">
|
||||
<PageContainer>
|
||||
<div
|
||||
ref={heroRef}
|
||||
class="flex flex-col items-center text-center py-20 md:py-32 transition-all duration-700 ease-out"
|
||||
style="opacity: 0; transform: translateY(20px);"
|
||||
>
|
||||
<div class="mb-6 shadow-glow-primary rounded-full p-3">
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg" class="w-12 h-12 md:w-16 md:h-16">
|
||||
<circle cx="24" cy="24" r="24" fill="var(--color-brand-primary)" />
|
||||
<path d="M24 10L16 14v6.5c0 5.1 3.4 9.9 8 11.5 4.6-1.6 8-6.4 8-11.5V14l-8-4z" fill="white" fill-opacity="0.9" />
|
||||
<path d="M20 24l3 2.5 5-5" stroke="var(--color-brand-primary)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<h1 class="text-4xl md:text-6xl lg:text-7xl font-bold tracking-tight mb-6 max-w-4xl">
|
||||
<Typewriter speed={50} delay={400} keepAlive={false}>
|
||||
<span class="text-text-primary">AI-Powered </span>
|
||||
<span class="text-gradient-primary">Identity Protection</span>
|
||||
<br />
|
||||
<span class="text-text-primary">for Everyone</span>
|
||||
</Typewriter>
|
||||
</h1>
|
||||
|
||||
<p class="text-xl md:text-2xl text-text-secondary max-w-2xl mb-10 leading-relaxed">
|
||||
Threat actors are using AI in multifaceted attacks. Kordant evens
|
||||
the playing field using advanced AI to monitor, detect, and prevent
|
||||
identity threats in real-time.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 mb-8">
|
||||
<A href="/signup">
|
||||
<Button variant="primary" size="lg">Get Started</Button>
|
||||
</A>
|
||||
<A href="#features">
|
||||
<Button variant="ghost" size="lg">Learn More</Button>
|
||||
</A>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-sm text-text-tertiary">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M6.5 11.5L3 8L4.1 6.9L6.5 9.3L12.4 3.4L13.5 4.5L6.5 11.5Z" fill="var(--color-success)" /></svg>
|
||||
No credit card required
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M6.5 11.5L3 8L4.1 6.9L6.5 9.3L12.4 3.4L13.5 4.5L6.5 11.5Z" fill="var(--color-success)" /></svg>
|
||||
Free tier available
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
|
||||
{/* How It Works */}
|
||||
<div
|
||||
class="bg-dot-grid relative z-10"
|
||||
<section
|
||||
id="how-it-works"
|
||||
class="relative z-10 bg-dot-grid scroll-mt-16"
|
||||
style={{ "clip-path": "polygon(0 var(--cut), 100% 0, 100% 100%, 0 100%)" }}
|
||||
>
|
||||
<section id="how-it-works" class="py-20 md:py-28 scroll-mt-16">
|
||||
<PageContainer py="py-8">
|
||||
<PageContainer py="py-8">
|
||||
<div class="py-20 md:py-28">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
How It Works
|
||||
@@ -333,17 +334,18 @@ export default function Home() {
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
|
||||
{/* Platform Features */}
|
||||
<div
|
||||
class="relative z-10 backdrop-blur-2xl bg-bg/40 py-8 -my-10"
|
||||
<section
|
||||
id="features"
|
||||
class="relative z-10 backdrop-blur-2xl bg-bg/40 py-8 -my-10 scroll-mt-16"
|
||||
style={{ "clip-path": "polygon(0 0, 100% 0, 100% calc(100% - var(--cut)), 0 100%)" }}
|
||||
>
|
||||
<section id="features" class="py-20 md:py-28 scroll-mt-16">
|
||||
<PageContainer py="py-8">
|
||||
<PageContainer py="py-8">
|
||||
<div class="py-20 md:py-28">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
Platform Features
|
||||
@@ -373,17 +375,18 @@ export default function Home() {
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
|
||||
{/* For Everyone */}
|
||||
<div
|
||||
class="bg-dot-grid"
|
||||
<section
|
||||
id="for-users"
|
||||
class="relative z-10 bg-dot-grid scroll-mt-16"
|
||||
style={{ "clip-path": "polygon(0 var(--cut), 100% 0, 100% 100%, 0 100%)" }}
|
||||
>
|
||||
<section id="for-users" class="py-20 md:py-28 scroll-mt-16">
|
||||
<PageContainer py="py-8">
|
||||
<PageContainer py="py-8">
|
||||
<div class="py-20 md:py-28">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
For Everyone
|
||||
@@ -421,17 +424,18 @@ export default function Home() {
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
|
||||
{/* Why Kordant + CTA */}
|
||||
<div
|
||||
class="relative z-10 backdrop-blur-2xl bg-bg/40 pt-8 -mt-10"
|
||||
<section
|
||||
id="why-kordant"
|
||||
class="relative z-10 backdrop-blur-2xl bg-bg/40 pt-8 -mt-10 scroll-mt-16"
|
||||
style={{ "clip-path": "polygon(0 var(--cut), 100% 0, 100% 100%, 0 100%)" }}
|
||||
>
|
||||
<section id="why-kordant" class="py-20 md:py-28 scroll-mt-16">
|
||||
<PageContainer py="py-8">
|
||||
<PageContainer py="py-8">
|
||||
<div class="py-20 md:py-28">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
Why Kordant
|
||||
@@ -469,31 +473,33 @@ export default function Home() {
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
</div>
|
||||
</PageContainer>
|
||||
|
||||
<section id="cta" class="py-20 md:py-28 scroll-mt-16">
|
||||
<section id="cta" class="scroll-mt-16">
|
||||
<PageContainer py="py-8">
|
||||
<div class="gradient-card border border-(--color-border)/50 rounded-2xl p-10 md:p-16 text-center">
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
Ready to protect your identity?
|
||||
</h2>
|
||||
<p class="text-lg text-[var(--color-text-secondary)] max-w-2xl mx-auto mb-10">
|
||||
Join thousands of users who trust Kordant to keep their digital
|
||||
identity safe from emerging threats.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<A href="/signup">
|
||||
<Button variant="primary" size="lg">Create Account</Button>
|
||||
</A>
|
||||
<A href="/login">
|
||||
<Button variant="secondary" size="lg">Sign In</Button>
|
||||
</A>
|
||||
<div class="py-20 md:py-28">
|
||||
<div class="gradient-card border border-(--color-border)/50 rounded-2xl p-10 md:p-16 text-center">
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-bold text-[var(--color-text-primary)] mb-4">
|
||||
Ready to protect your identity?
|
||||
</h2>
|
||||
<p class="text-lg text-[var(--color-text-secondary)] max-w-2xl mx-auto mb-10">
|
||||
Join thousands of users who trust Kordant to keep their digital
|
||||
identity safe from emerging threats.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<A href="/signup">
|
||||
<Button variant="primary" size="lg">Create Account</Button>
|
||||
</A>
|
||||
<A href="/login">
|
||||
<Button variant="secondary" size="lg">Sign In</Button>
|
||||
</A>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,12 @@ const plans: Plan[] = [
|
||||
price: "$9",
|
||||
period: "/month",
|
||||
description: "Essential identity protection for individuals",
|
||||
features: ["Dark web monitoring", "Email breach alerts", "Basic scam call blocking", "Monthly reports"],
|
||||
features: [
|
||||
"Dark web monitoring",
|
||||
"Email breach alerts",
|
||||
"Basic scam call blocking",
|
||||
"Monthly reports",
|
||||
],
|
||||
cta: "Start Free Trial",
|
||||
popular: false,
|
||||
},
|
||||
@@ -35,7 +40,13 @@ const plans: Plan[] = [
|
||||
price: "$19",
|
||||
period: "/month",
|
||||
description: "Advanced protection for you and your family",
|
||||
features: ["Everything in Basic", "VoicePrint AI detection", "HomeTitle fraud alerts", "RemoveBrokers automation", "Family sharing (up to 5)"],
|
||||
features: [
|
||||
"Everything in Basic",
|
||||
"VoicePrint AI detection",
|
||||
"HomeTitle fraud alerts",
|
||||
"RemoveBrokers automation",
|
||||
"Family sharing (up to 5)",
|
||||
],
|
||||
cta: "Start Free Trial",
|
||||
popular: true,
|
||||
},
|
||||
@@ -44,7 +55,14 @@ const plans: Plan[] = [
|
||||
price: "$39",
|
||||
period: "/month",
|
||||
description: "Maximum security for the whole household",
|
||||
features: ["Everything in Plus", "Unlimited family members", "Priority support 24/7", "Real-time alert correlation", "Advanced analytics dashboard", "Data broker suppression"],
|
||||
features: [
|
||||
"Everything in Plus",
|
||||
"Unlimited family members",
|
||||
"Priority support 24/7",
|
||||
"Real-time alert correlation",
|
||||
"Advanced analytics dashboard",
|
||||
"Data broker suppression",
|
||||
],
|
||||
cta: "Start Free Trial",
|
||||
popular: false,
|
||||
},
|
||||
@@ -80,30 +98,90 @@ const faqs: FAQ[] = [
|
||||
const comparisonFeatures = [
|
||||
{ feature: "Dark web monitoring", basic: true, plus: true, premium: true },
|
||||
{ feature: "Email breach alerts", basic: true, plus: true, premium: true },
|
||||
{ feature: "Basic scam call blocking", basic: true, plus: true, premium: true },
|
||||
{
|
||||
feature: "Basic scam call blocking",
|
||||
basic: true,
|
||||
plus: true,
|
||||
premium: true,
|
||||
},
|
||||
{ feature: "Monthly reports", basic: true, plus: true, premium: true },
|
||||
{ feature: "VoicePrint AI detection", basic: false, plus: true, premium: true },
|
||||
{ feature: "HomeTitle fraud alerts", basic: false, plus: true, premium: true },
|
||||
{ feature: "RemoveBrokers automation", basic: false, plus: true, premium: true },
|
||||
{ feature: "Family sharing", basic: false, plus: "Up to 5", premium: "Unlimited" },
|
||||
{ feature: "Priority support 24/7", basic: false, plus: false, premium: true },
|
||||
{ feature: "Real-time alert correlation", basic: false, plus: false, premium: true },
|
||||
{
|
||||
feature: "VoicePrint AI detection",
|
||||
basic: false,
|
||||
plus: true,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
feature: "HomeTitle fraud alerts",
|
||||
basic: false,
|
||||
plus: true,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
feature: "RemoveBrokers automation",
|
||||
basic: false,
|
||||
plus: true,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
feature: "Family sharing",
|
||||
basic: false,
|
||||
plus: "Up to 5",
|
||||
premium: "Unlimited",
|
||||
},
|
||||
{
|
||||
feature: "Priority support 24/7",
|
||||
basic: false,
|
||||
plus: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
feature: "Real-time alert correlation",
|
||||
basic: false,
|
||||
plus: false,
|
||||
premium: true,
|
||||
},
|
||||
{ feature: "Advanced analytics", basic: false, plus: false, premium: true },
|
||||
{ feature: "Data broker suppression", basic: false, plus: false, premium: true },
|
||||
{
|
||||
feature: "Data broker suppression",
|
||||
basic: false,
|
||||
plus: false,
|
||||
premium: true,
|
||||
},
|
||||
];
|
||||
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" class="flex-shrink-0">
|
||||
<path d="M6.5 11.5L3 8l1.1-1.1L6.5 9.3l5.9-5.9L13.5 4.5l-7 7z" fill="var(--color-success)"/>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
class="flex-shrink-0"
|
||||
>
|
||||
<path
|
||||
d="M6.5 11.5L3 8l1.1-1.1L6.5 9.3l5.9-5.9L13.5 4.5l-7 7z"
|
||||
fill="var(--color-success)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function XIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" class="flex-shrink-0">
|
||||
<path d="M4 4l8 8M12 4l-8 8" stroke="var(--color-text-muted)" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
class="flex-shrink-0"
|
||||
>
|
||||
<path
|
||||
d="M4 4l8 8M12 4l-8 8"
|
||||
stroke="var(--color-text-muted)"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -112,33 +190,52 @@ export default function PricingPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [openFaq, setOpenFaq] = createSignal<string | null>(null);
|
||||
|
||||
const signupUrl = () => `/signup${searchParams.utm_source ? `?utm_source=${searchParams.utm_source}&utm_medium=${searchParams.utm_medium || ""}&utm_campaign=${searchParams.utm_campaign || ""}` : ""}`;
|
||||
const signupUrl = () =>
|
||||
`/signup${
|
||||
searchParams.utm_source
|
||||
? `?utm_source=${searchParams.utm_source}&utm_medium=${
|
||||
searchParams.utm_medium || ""
|
||||
}&utm_campaign=${searchParams.utm_campaign || ""}`
|
||||
: ""
|
||||
}`;
|
||||
|
||||
const planToId: Record<string, string> = {
|
||||
Basic: "basic",
|
||||
Plus: "plus",
|
||||
Premium: "premium",
|
||||
};
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Title>Kordant Pricing — AI-Powered Identity Protection Plans</Title>
|
||||
|
||||
<section class="relative py-20 md:py-28 overflow-hidden">
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-[var(--color-brand-primary)]/5 to-transparent" />
|
||||
<div class="absolute inset-0 bg-linear-to-b from-(--color-brand-primary)/5 to-transparent" />
|
||||
<PageContainer class="relative z-10">
|
||||
<div class="text-center max-w-3xl mx-auto">
|
||||
<Badge variant="info" class="mb-4">Simple Pricing</Badge>
|
||||
<h1 class="text-4xl md:text-5xl lg:text-6xl font-bold text-[var(--color-text-primary)] mb-6">
|
||||
<Badge variant="info" class="mb-4">
|
||||
Simple Pricing
|
||||
</Badge>
|
||||
<h1 class="text-4xl md:text-5xl lg:text-6xl font-bold text-text-primary mb-6">
|
||||
Protection That Fits{" "}
|
||||
<span class="text-gradient-primary">Your Budget</span>
|
||||
</h1>
|
||||
<p class="text-xl text-[var(--color-text-secondary)] mb-8 max-w-2xl mx-auto">
|
||||
Start with a 14-day free trial. No credit card required. Cancel anytime.
|
||||
<p class="text-xl text-text-secondary mb-8 max-w-2xl mx-auto">
|
||||
Start with a 14-day free trial. No credit card required. Cancel
|
||||
anytime.
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-sm text-[var(--color-text-tertiary)]">
|
||||
<div class="flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-sm text-text-tertiary">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<CheckIcon />14-day free trial
|
||||
<CheckIcon />
|
||||
14-day free trial
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<CheckIcon />No credit card required
|
||||
<CheckIcon />
|
||||
No credit card required
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<CheckIcon />Cancel anytime
|
||||
<CheckIcon />
|
||||
Cancel anytime
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -153,7 +250,8 @@ export default function PricingPage() {
|
||||
<Card
|
||||
class={cn(
|
||||
"relative flex flex-col",
|
||||
plan.popular && "ring-2 ring-[var(--color-brand-primary)] shadow-glow-primary",
|
||||
plan.popular &&
|
||||
"ring-2 ring-(--color-brand-primary) shadow-glow-primary",
|
||||
)}
|
||||
>
|
||||
<Show when={plan.popular}>
|
||||
@@ -162,25 +260,36 @@ export default function PricingPage() {
|
||||
</div>
|
||||
</Show>
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold text-[var(--color-text-primary)] mb-1">{plan.name}</h3>
|
||||
<p class="text-sm text-[var(--color-text-secondary)] mb-4">{plan.description}</p>
|
||||
<h3 class="text-lg font-semibold text-text-primary mb-1">
|
||||
{plan.name}
|
||||
</h3>
|
||||
<p class="text-sm text-text-secondary mb-4">
|
||||
{plan.description}
|
||||
</p>
|
||||
<div class="flex items-baseline gap-0.5">
|
||||
<span class="text-4xl font-bold text-[var(--color-text-primary)]">{plan.price}</span>
|
||||
<span class="text-sm text-[var(--color-text-tertiary)]">{plan.period}</span>
|
||||
<span class="text-4xl font-bold text-text-primary">
|
||||
{plan.price}
|
||||
</span>
|
||||
<span class="text-sm text-text-tertiary">
|
||||
{plan.period}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="space-y-3 mb-8 flex-1">
|
||||
<For each={plan.features}>
|
||||
{(feature) => (
|
||||
<li class="flex items-start gap-2 text-sm text-[var(--color-text-secondary)]">
|
||||
<li class="flex items-start gap-2 text-sm text-text-secondary">
|
||||
<CheckIcon />
|
||||
{feature}
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
<A href={signupUrl()}>
|
||||
<Button variant={plan.popular ? "primary" : "secondary"} class="w-full">
|
||||
<A href={`/billing/checkout?plan=${planToId[plan.name]}`}>
|
||||
<Button
|
||||
variant={plan.popular ? "primary" : "secondary"}
|
||||
class="w-full"
|
||||
>
|
||||
{plan.cta}
|
||||
</Button>
|
||||
</A>
|
||||
@@ -202,25 +311,59 @@ export default function PricingPage() {
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b-2 border-[var(--color-border)]">
|
||||
<th class="text-left px-4 py-3 text-sm font-medium text-[var(--color-text-secondary)]">Feature</th>
|
||||
<th class="text-center px-4 py-3 text-sm font-medium text-[var(--color-text-secondary)]">Basic</th>
|
||||
<th class="text-center px-4 py-3 text-sm font-semibold text-[var(--color-brand-primary)]">Plus</th>
|
||||
<th class="text-center px-4 py-3 text-sm font-medium text-[var(--color-text-secondary)]">Premium</th>
|
||||
<th class="text-left px-4 py-3 text-sm font-medium text-[var(--color-text-secondary)]">
|
||||
Feature
|
||||
</th>
|
||||
<th class="text-center px-4 py-3 text-sm font-medium text-[var(--color-text-secondary)]">
|
||||
Basic
|
||||
</th>
|
||||
<th class="text-center px-4 py-3 text-sm font-semibold text-[var(--color-brand-primary)]">
|
||||
Plus
|
||||
</th>
|
||||
<th class="text-center px-4 py-3 text-sm font-medium text-[var(--color-text-secondary)]">
|
||||
Premium
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={comparisonFeatures}>
|
||||
{(row) => (
|
||||
<tr class="border-b border-[var(--color-border)]">
|
||||
<td class="px-4 py-3 text-sm text-[var(--color-text-primary)]">{row.feature}</td>
|
||||
<td class="text-center px-4 py-3">
|
||||
{row.basic === true ? <CheckIcon /> : row.basic === false ? <XIcon /> : <span class="text-xs text-[var(--color-text-secondary)]">{row.basic}</span>}
|
||||
<td class="px-4 py-3 text-sm text-[var(--color-text-primary)]">
|
||||
{row.feature}
|
||||
</td>
|
||||
<td class="text-center px-4 py-3">
|
||||
{row.plus === true ? <CheckIcon /> : row.plus === false ? <XIcon /> : <span class="text-xs text-[var(--color-text-secondary)]">{row.plus}</span>}
|
||||
{row.basic === true ? (
|
||||
<CheckIcon />
|
||||
) : row.basic === false ? (
|
||||
<XIcon />
|
||||
) : (
|
||||
<span class="text-xs text-[var(--color-text-secondary)]">
|
||||
{row.basic}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td class="text-center px-4 py-3">
|
||||
{row.premium === true ? <CheckIcon /> : row.premium === false ? <XIcon /> : <span class="text-xs text-[var(--color-text-secondary)]">{row.premium}</span>}
|
||||
{row.plus === true ? (
|
||||
<CheckIcon />
|
||||
) : row.plus === false ? (
|
||||
<XIcon />
|
||||
) : (
|
||||
<span class="text-xs text-[var(--color-text-secondary)]">
|
||||
{row.plus}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td class="text-center px-4 py-3">
|
||||
{row.premium === true ? (
|
||||
<CheckIcon />
|
||||
) : row.premium === false ? (
|
||||
<XIcon />
|
||||
) : (
|
||||
<span class="text-xs text-[var(--color-text-secondary)]">
|
||||
{row.premium}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -244,7 +387,7 @@ export default function PricingPage() {
|
||||
{(faq) => {
|
||||
const isOpen = () => openFaq() === faq.q;
|
||||
return (
|
||||
<div class="border border-[var(--color-border)] rounded-xl overflow-hidden">
|
||||
<div class="border border-(--color-border) rounded-xl overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center justify-between px-5 py-4 text-left text-sm font-medium text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)] transition-colors"
|
||||
@@ -256,9 +399,18 @@ export default function PricingPage() {
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
class={cn("transition-transform duration-200", isOpen() && "rotate-180")}
|
||||
class={cn(
|
||||
"transition-transform duration-200",
|
||||
isOpen() && "rotate-180",
|
||||
)}
|
||||
>
|
||||
<path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path
|
||||
d="M4 6l4 4 4-4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<Show when={isOpen()}>
|
||||
@@ -275,17 +427,22 @@ export default function PricingPage() {
|
||||
</PageContainer>
|
||||
</section>
|
||||
|
||||
<section class="py-16 bg-[var(--color-brand-primary)]">
|
||||
<section class="py-16 bg-(--color-brand-primary)">
|
||||
<PageContainer>
|
||||
<div class="text-center">
|
||||
<h2 class="text-3xl md:text-4xl font-bold text-white mb-4">
|
||||
Ready to protect your identity?
|
||||
</h2>
|
||||
<p class="text-lg text-white/80 mb-8 max-w-2xl mx-auto">
|
||||
Join 50,000+ users who trust Kordant for AI-powered identity protection.
|
||||
Join 50,000+ users who trust Kordant for AI-powered identity
|
||||
protection.
|
||||
</p>
|
||||
<A href={signupUrl()}>
|
||||
<Button variant="primary" size="lg" class="bg-white text-[var(--color-brand-primary)] hover:bg-white/90 shadow-lg">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
class="bg-white text-[var(--color-brand-primary)] hover:bg-white/90 shadow-lg"
|
||||
>
|
||||
Get Started Free
|
||||
</Button>
|
||||
</A>
|
||||
|
||||
@@ -84,8 +84,8 @@ function createCaller(user: User | null) {
|
||||
createCheckoutSession: t.procedure.use(isAuthed)
|
||||
.input(wrap(CreateCheckoutSessionSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const i = input as { priceId: string; successUrl: string; cancelUrl: string };
|
||||
return mockCreateCheckoutSession(ctx.user.id, ctx.user.email, i.priceId, i.successUrl, i.cancelUrl);
|
||||
const i = input as { priceId: string; returnUrl: string };
|
||||
return mockCreateCheckoutSession(ctx.user.id, ctx.user.email, i.priceId, i.returnUrl);
|
||||
}),
|
||||
createPortalSession: t.procedure.use(isAuthed)
|
||||
.input(wrap(CreatePortalSessionSchema))
|
||||
@@ -159,20 +159,20 @@ describe("billing.getSubscription", () => {
|
||||
});
|
||||
|
||||
describe("billing.createCheckoutSession", () => {
|
||||
it("creates checkout session and returns URL", async () => {
|
||||
it("creates checkout session and returns clientSecret", async () => {
|
||||
mockCreateCheckoutSession.mockResolvedValue({
|
||||
url: "https://checkout.stripe.com/session_123",
|
||||
clientSecret: "cs_123_secret",
|
||||
sessionId: "session_123",
|
||||
});
|
||||
|
||||
const api = createCaller(makeUser());
|
||||
const result = await api.createCheckoutSession({
|
||||
priceId: "price_basic",
|
||||
successUrl: "https://example.com/success",
|
||||
cancelUrl: "https://example.com/cancel",
|
||||
returnUrl: "https://example.com/return",
|
||||
});
|
||||
|
||||
expect(result.url).toBe("https://checkout.stripe.com/session_123");
|
||||
expect(result.clientSecret).toBe("cs_123_secret");
|
||||
expect(result.sessionId).toBe("session_123");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -48,8 +48,7 @@ export const billingRouter = createTRPCRouter({
|
||||
ctx.user.id,
|
||||
ctx.user.email,
|
||||
input.priceId,
|
||||
input.successUrl,
|
||||
input.cancelUrl,
|
||||
input.returnUrl,
|
||||
);
|
||||
}),
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@ import { object, string, url, minLength, optional, picklist } from "valibot";
|
||||
|
||||
export const CreateCheckoutSessionSchema = object({
|
||||
priceId: string([minLength(1)]),
|
||||
successUrl: string([url()]),
|
||||
cancelUrl: string([url()]),
|
||||
returnUrl: string([url()]),
|
||||
});
|
||||
|
||||
export const CreatePortalSessionSchema = object({
|
||||
|
||||
@@ -100,7 +100,7 @@ describe("getOrCreateCustomer", () => {
|
||||
});
|
||||
|
||||
describe("createCheckoutSession", () => {
|
||||
it("creates a Stripe checkout session and returns URL", async () => {
|
||||
it("creates an embedded Stripe checkout session and returns clientSecret", async () => {
|
||||
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
@@ -112,20 +112,25 @@ describe("createCheckoutSession", () => {
|
||||
});
|
||||
|
||||
(stripe.checkout.sessions.create as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
url: "https://checkout.stripe.com/session_123",
|
||||
id: "session_123",
|
||||
client_secret: "cs_123_secret",
|
||||
});
|
||||
|
||||
const result = await createCheckoutSession(
|
||||
"u1",
|
||||
"a@b.com",
|
||||
"price_basic",
|
||||
"https://example.com/success",
|
||||
"https://example.com/cancel",
|
||||
"https://example.com/return",
|
||||
);
|
||||
|
||||
expect(result.url).toBe("https://checkout.stripe.com/session_123");
|
||||
expect(result.clientSecret).toBe("cs_123_secret");
|
||||
expect(result.sessionId).toBe("session_123");
|
||||
expect(stripe.checkout.sessions.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ui_mode: "embedded_page",
|
||||
return_url: "https://example.com/return?session_id={CHECKOUT_SESSION_ID}",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -40,21 +40,20 @@ export async function createCheckoutSession(
|
||||
userId: string,
|
||||
email: string,
|
||||
priceId: string,
|
||||
successUrl: string,
|
||||
cancelUrl: string,
|
||||
returnUrl: string,
|
||||
) {
|
||||
const customerId = await getOrCreateCustomer(userId, email);
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
mode: "subscription",
|
||||
ui_mode: "embedded_page",
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
return_url: `${returnUrl}?session_id={CHECKOUT_SESSION_ID}`,
|
||||
metadata: { userId },
|
||||
});
|
||||
|
||||
return { url: session.url, sessionId: session.id };
|
||||
return { clientSecret: session.client_secret ?? "", sessionId: session.id };
|
||||
}
|
||||
|
||||
export async function createPortalSession(customerId: string, returnUrl: string) {
|
||||
|
||||
Reference in New Issue
Block a user